Reputation: 5647
I'm building a declarative and type-based filter model. I'm stuck storing the state of the active filters in a property, because my Protocols have associated types.``
I heard about Type Erasure, but all examples I found where using super easy examples and somehow I can't map it to my use case.
This is my protocol:
protocol Filter {
// The Type to be filtered (`MyModel`)
associatedtype ParentType
// The type of the property to be filtered (e.g `Date`)
associatedtype InputType
// The type of the possible FilterOption (e.g. `DateFilterOption` or the same as the Input type for filtering in enums.)
associatedtype OptionType
// This should return a list of all possible filter options
static var allOptions: [OptionType] { get }
static var allowsMultipleSelection: Bool { get }
// the adopting object will be setting this.
var selectedOptions: [OptionType] { get set }
func isIncluded(_ item: InputType) -> Bool
// For getting reference to the specific property. I think Swift 4's keypaths could be working here too.
var filter: FilterClosure<ParentType> { get }
}
And sub protocols which have extensions for reducing copy/paste-code
protocol EquatableFilter: Filter where InputType: Equatable, OptionType == InputType {}
extension EquatableFilter {
var allowsMultipleSelection: Bool { return true }
func isIncluded(_ item: InputType) -> Bool {
if selectedOptions.count == 0 { return true }
return selectedOptions.contains(item)
}
}
// Another specific filter. See gist file for extension.
protocol DateFilter: Filter where InputType == Date, OptionType == DateFilterOption {}
For more code, please see my gist to see how my implementation looks, with a example model.
How can I store an array containing struct
instances, conforming to
different Filter
protocols?
And how can I store a static array, containing only the types of the structs, so I can access the static properties?
Upvotes: 2
Views: 298
Reputation: 299325
Interestingly, I built something not unlike this earlier this year for a commercial project. It's challenging to do in a general way, but most of the problems come from backwards thinking. "Start with the end in mind."
// I want to be able to filter a sequence like this:
let newArray = myArray.filteredBy([
MyModel.Filters.DueDateFilter(selectedOptions: [.in24hours(past: false)]),
MyModel.Filters.StatusFilter(selectedOptions: [.a, .b])
])
This part is very straightforward. It doesn't even require filteredBy
. Just add .filter
to each element:
let newArray = myArray
.filter(MyModel.Filters.DueDateFilter(selectedOptions: [.in24hours(past: false)]).filter)
.filter(MyModel.Filters.StatusFilter(selectedOptions: [.a, .b]).filter)
If you wanted to, you can write filtered by this way, and do the same thing:
func filteredBy(_ filters: [(Element) -> Bool]) -> [Element] {...}
The point is that Filter
here isn't really a "filter." It's a description of a filter, with lots of other things attached about the UI (we'll talk about that more later). To actually filter, all you need is (Element) -> Bool
.
What we really want here is a way to build up a ([Element]) -> Element
with a nice, expressive syntax. In a functional language, that'd be pretty straightforward because we'd have things like partial application and functional composition. But Swift doesn't really like doing those things, so to make it prettier, let's build some structs.
struct Filter<Element> {
let isIncluded: (Element) -> Bool
}
struct Map<Input, Output> {
let transform: (Input) -> Output
}
We'll need a way to get started, so let's use an identity map
extension Map where Input == Output {
init(on: Input.Type) { transform = { $0 }}
}
And we'll want a way to think about keyPaths
extension Map {
func keyPath<ChildOutput>(_ keyPath: KeyPath<Input, ChildOutput>) -> Map<Input, ChildOutput> {
return Map<Input, ChildOutput>(transform: { $0[keyPath: keyPath] })
}
}
And finally we'll want to create an actual filter
extension Map {
func inRange<RE: RangeExpression>(_ range: RE) -> Filter<Input> where RE.Bound == Output {
let transform = self.transform
return Filter(isIncluded: { range.contains(transform($0)) })
}
}
Add a helper for "last 24 hours"
extension Range where Bound == Date {
static var last24Hours: Range<Date> { return Date(timeIntervalSinceNow: -24*60*60)..<Date() }
}
And now we can build a filter that looks like:
let filters = [Map(on: MyModel.self).keyPath(\.dueDate).inRange(Range.last24Hours)]
filters
is of type Filter<MyModel>
, so any other thing that filters MyModel
is legitimate here. Tweaking your filteredBy
:
extension Sequence {
func filteredBy(_ filters: [Filter<Element>]) -> [Element] {
return filter{ element in filters.allSatisfy{ $0.isIncluded(element) } }
}
}
OK, that's the filtering step. But your problem is also basically "UI configuration" and for that you want to capture a lot more elements than this does.
But your example usage won't get you there:
// Also I want to be able to save the state of all filters like this
var activeFilters: [AnyFilter] = [ // ???
MyModel.Filters.DueDateFilter(selectedOptions: [.in24hours(past: false)]),
MyModel.Filters.StatusFilter(selectedOptions: [.a, .b])
]
How can you transform AnyFilter
into UI elements? Your filter protocol allows literally any option type. How would you display the UI for that if the option type were OutputStream
or DispatchQueue
? The type you've created doesn't address the problem.
Here's one way to go about it. Create a FilterComponent struct that defines the needed UI elements and provides a way to construct a filter.
struct FilterComponent<Model> {
let optionTitles: [String]
let allowsMultipleSelection: Bool
var selectedOptions: IndexSet
let makeFilter: (IndexSet) -> Filter<Model>
}
Then to create a date filter component, we need some options for dates.
enum DateOptions: String, CaseIterable {
case inPast24hours = "In the past 24 hours"
case inNext24hours = "In the next 24 hours"
var dateRange: Range<Date> {
switch self {
case .inPast24hours: return Date(timeIntervalSinceNow: -24*60*60)..<Date()
case .inNext24hours: return Date()..<Date(timeIntervalSinceNow: -24*60*60)
}
}
}
And then we want a way to create such a component with the correct makeFilter
:
extension FilterComponent {
static func byDate(ofField keyPath: KeyPath<Model, Date>) -> FilterComponent<Model> {
return FilterComponent(optionTitles: DateOptions.allCases.map{ $0.rawValue },
allowsMultipleSelection: false,
selectedOptions: [],
makeFilter: { indexSet in
guard let index = indexSet.first else {
return Filter<Model> { _ in true }
}
let range = DateOptions.allCases[index].dateRange
return Map(on: Model.self).keyPath(keyPath).inRange(range)
})
}
}
With all that, we can create components of type FilterComponent<MyModel>
. No internal types (like Date
) have to be exposed. No protocols needed.
let components = [FilterComponent.byDate(ofField: \MyModel.dueDate)]
Upvotes: 3