Skip to content

Commit ec74f98

Browse files
committed
Provide a way for custom attributes to decode from predicates
1 parent 2a651f8 commit ec74f98

32 files changed

Lines changed: 520 additions & 158 deletions

Example/PredicateViewExample.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
4A7236902CA8A677003DD1EC /* CustomExpressionDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A72368F2CA8A677003DD1EC /* CustomExpressionDemoView.swift */; };
11+
4A7236922CA8A877003DD1EC /* PredicateDecodingDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7236912CA8A877003DD1EC /* PredicateDecodingDemoView.swift */; };
12+
4A7236942CA8A9B3003DD1EC /* ReadOnlyDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7236932CA8A9B3003DD1EC /* ReadOnlyDemoView.swift */; };
1013
4ACACCED2C9F5C65003FA734 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ACACCE82C9F5C65003FA734 /* ContentView.swift */; };
1114
4ACACCEE2C9F5C65003FA734 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ACACCE92C9F5C65003FA734 /* Item.swift */; };
1215
4ACACCEF2C9F5C65003FA734 /* PredicateViewExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ACACCEB2C9F5C65003FA734 /* PredicateViewExampleApp.swift */; };
@@ -19,6 +22,9 @@
1922
/* End PBXBuildFile section */
2023

2124
/* Begin PBXFileReference section */
25+
4A72368F2CA8A677003DD1EC /* CustomExpressionDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomExpressionDemoView.swift; sourceTree = "<group>"; };
26+
4A7236912CA8A877003DD1EC /* PredicateDecodingDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredicateDecodingDemoView.swift; sourceTree = "<group>"; };
27+
4A7236932CA8A9B3003DD1EC /* ReadOnlyDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadOnlyDemoView.swift; sourceTree = "<group>"; };
2228
4ACACCD12C9F5C56003FA734 /* PredicateViewExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PredicateViewExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
2329
4ACACCE52C9F5C65003FA734 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
2430
4ACACCE72C9F5C65003FA734 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -74,6 +80,9 @@
7480
4ACACCE82C9F5C65003FA734 /* ContentView.swift */,
7581
4ACACCFC2C9F5D6C003FA734 /* PredicateDemoView.swift */,
7682
4ACACCFF2C9F620E003FA734 /* SwiftDataDemoView.swift */,
83+
4A72368F2CA8A677003DD1EC /* CustomExpressionDemoView.swift */,
84+
4A7236912CA8A877003DD1EC /* PredicateDecodingDemoView.swift */,
85+
4A7236932CA8A9B3003DD1EC /* ReadOnlyDemoView.swift */,
7786
4ACACCE92C9F5C65003FA734 /* Item.swift */,
7887
4ACACCEA2C9F5C65003FA734 /* PredicateViewExample.entitlements */,
7988
4ACACCE72C9F5C65003FA734 /* Assets.xcassets */,
@@ -162,9 +171,12 @@
162171
files = (
163172
4ACACCED2C9F5C65003FA734 /* ContentView.swift in Sources */,
164173
4ACACCEE2C9F5C65003FA734 /* Item.swift in Sources */,
174+
4A7236902CA8A677003DD1EC /* CustomExpressionDemoView.swift in Sources */,
165175
4ACACD002C9F620E003FA734 /* SwiftDataDemoView.swift in Sources */,
166176
4ACACCEF2C9F5C65003FA734 /* PredicateViewExampleApp.swift in Sources */,
167177
4ACACCFD2C9F5D6C003FA734 /* PredicateDemoView.swift in Sources */,
178+
4A7236922CA8A877003DD1EC /* PredicateDecodingDemoView.swift in Sources */,
179+
4A7236942CA8A9B3003DD1EC /* ReadOnlyDemoView.swift in Sources */,
168180
);
169181
runOnlyForDeploymentPostprocessing = 0;
170182
};

Example/PredicateViewExample/ContentView.swift

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ struct ContentView: View {
1313
var body: some View {
1414
NavigationSplitView {
1515
List {
16-
NavigationLink("Simple") {
16+
NavigationLink("Simple Data Model") {
1717
PredicateDemoView()
18-
.navigationTitle("PredicateView")
18+
.navigationTitle("Simple Data Model")
1919
.toolbarTitleDisplayMode(.inline)
2020
}
2121

@@ -24,6 +24,24 @@ struct ContentView: View {
2424
.navigationTitle("SwiftData")
2525
.toolbarTitleDisplayMode(.inline)
2626
}
27+
28+
NavigationLink("Custom Expressions") {
29+
CustomExpressionDemoView()
30+
.navigationTitle("Custom Expressions")
31+
.toolbarTitleDisplayMode(.inline)
32+
}
33+
34+
NavigationLink("Predicate Decoding") {
35+
PredicateDecodingDemoView()
36+
.navigationTitle("Predicate Decoding")
37+
.toolbarTitleDisplayMode(.inline)
38+
}
39+
40+
NavigationLink("Read Only") {
41+
ReadOnlyDemoView()
42+
.navigationTitle("Read Only")
43+
.toolbarTitleDisplayMode(.inline)
44+
}
2745
}
2846
#if os(macOS)
2947
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
@@ -32,24 +50,4 @@ struct ContentView: View {
3250
Text("Select an item")
3351
}
3452
}
35-
36-
// private func addItem() {
37-
// withAnimation {
38-
// let newItem = Item(timestamp: Date())
39-
// modelContext.insert(newItem)
40-
// }
41-
// }
42-
//
43-
// private func deleteItems(offsets: IndexSet) {
44-
// withAnimation {
45-
// for index in offsets {
46-
// modelContext.delete(items[index])
47-
// }
48-
// }
49-
// }
50-
}
51-
52-
#Preview {
53-
ContentView()
54-
.modelContainer(for: Item.self, inMemory: true)
5553
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//
2+
// CustomExpressionDemoView.swift
3+
// PredicateViewExample
4+
//
5+
// Created by Phil Zakharchenko on 9/28/24.
6+
//
7+
8+
import SwiftData
9+
import SwiftUI
10+
import PredicateView
11+
12+
// MARK: - StatusExpressionView
13+
14+
/// A custom expression view for handling status predicates in a predicate builder.
15+
struct StatusExpressionView: CustomExpressionView {
16+
/// Defines the operators available for status comparisons.
17+
enum Operator: String, ExpressionOperator {
18+
case equal = "is"
19+
case notEqual = "is not"
20+
}
21+
22+
/// The title of the expression view.
23+
static let title = "Status"
24+
25+
/// The key path to the status property of an ``Item``.
26+
static var keyPath: KeyPath<Item, Item.Status.RawValue?> { \._status }
27+
28+
/// The default value for the status.
29+
/// This will be the value picked automatically when the user inserts this view into an expression.
30+
static var defaultValue: Item.Status.RawValue? { Item.Status.todo.rawValue }
31+
32+
/// Creates a predicate for a given status value and operator.
33+
///
34+
/// - Parameters:
35+
/// - value: The status value to compare against.
36+
/// - op: The operator to use for comparison.
37+
/// - Returns: A `Predicate<Item>` representing the status condition.
38+
static func predicate(for value: Item.Status.RawValue?, operator op: Operator) -> Predicate<Item> {
39+
switch op {
40+
case .equal:
41+
return #Predicate<Item> { $0._status == value }
42+
case .notEqual:
43+
return #Predicate<Item> { $0._status != value }
44+
}
45+
}
46+
47+
/// Decodes a predicate expression into a `DecodedKeyPathExpression`.
48+
///
49+
/// - Parameter expression: The predicate expression to decode.
50+
/// - Returns: A `DecodedKeyPathExpression<Self>` if the expression can be decoded, otherwise `nil`.
51+
static func decode<PredicateExpressionType: PredicateExpression<Bool>>(
52+
_ expression: PredicateExpressionType
53+
) -> DecodedKeyPathExpression<Self>? {
54+
switch expression {
55+
case let expression as PredicateExpressions.Equal<KeyPathPredicateExpression, ValuePredicateExpression>:
56+
.init(keyPathExpression: expression.lhs, operator: .equal, value: expression.rhs.value)
57+
case let expression as PredicateExpressions.NotEqual<KeyPathPredicateExpression, ValuePredicateExpression>:
58+
.init(keyPathExpression: expression.lhs, operator: .notEqual, value: expression.rhs.value)
59+
default:
60+
nil
61+
}
62+
}
63+
64+
/// The binding to the current status value.
65+
@Binding var value: Item.Status.RawValue?
66+
67+
/// The body of the view, which creates a picker for selecting the status.
68+
var body: some View {
69+
Picker("", selection: $value) {
70+
ForEach(Item.Status.allCases, id: \.rawValue) { item in
71+
Text(item.rawValue)
72+
.tag(item.rawValue)
73+
}
74+
}
75+
}
76+
}
77+
78+
// MARK: - CustomExpressionDemoView
79+
80+
struct CustomExpressionDemoView: View {
81+
@Environment(\.modelContext) private var modelContext
82+
@State var predicate: Predicate<Item> = #Predicate<Item> {
83+
$0.title.localizedStandardContains("Item")
84+
}
85+
86+
var body: some View {
87+
VStack(alignment: .leading) {
88+
Text("This demo provides a sample implementation of the `CustomExpressionView` protocol that allows you to build custom expression views for key paths not covered by the standard set of built-in expressions. In this example, the status picker is a custom expression view that maps between an `Optional<String>` model representation and an enum value.")
89+
90+
predicateView(for: $predicate)
91+
92+
Table(items) {
93+
TableColumn("Title", value: \.title)
94+
TableColumn("Status", value: \.status.rawValue)
95+
TableColumn("Created") { value in
96+
Text(value.creationDate, style: .date)
97+
}
98+
TableColumn("Modified") { value in
99+
Text(value.modificationDate, style: .date)
100+
}
101+
}
102+
}
103+
.padding()
104+
}
105+
106+
private var items: [Item] {
107+
try! modelContext.fetch(.init(predicate: predicate))
108+
}
109+
110+
private func predicateView(for predicate: Binding<Predicate<Item>>) -> some View {
111+
ScrollView(.horizontal) {
112+
PredicateView(predicate: predicate, rowTemplates: [
113+
.init(keyPath: \.title, title: "Title"),
114+
.init(keyPath: \.creationDate, title: "Creation date"),
115+
.init(keyPath: \.modificationDate, title: "Modification date"),
116+
.init(StatusExpressionView.self),
117+
])
118+
}
119+
}
120+
}

Example/PredicateViewExample/Item.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ final class Item {
1515
}
1616

1717
var id: UUID = UUID()
18-
var timestamp: Date = Date()
18+
var creationDate: Date = Date()
19+
var modificationDate: Date = Date()
1920
var title: String = ""
2021
var _status: Status.RawValue? = nil
2122

2223
init() {
2324
self.id = UUID()
24-
self.timestamp = Date(timeIntervalSinceNow: .random(in: -3600 * 24 * 30...3600 * 24 * 30))
25+
self.creationDate = Date(timeIntervalSinceNow: .random(in: -3600 * 24 * 30...3600 * 24 * 30))
26+
self.modificationDate = Date(timeIntervalSinceNow: .random(in: -3600 * 24 * 30...3600 * 24 * 30))
2527
self.title = "Item \(Int.random(in: 1...1000))"
2628
self._status = Status.allCases.randomElement()!.rawValue
2729
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//
2+
// PredicateDecodingDemoView.swift
3+
// PredicateViewExample
4+
//
5+
// Created by Phil Zakharchenko on 9/28/24.
6+
//
7+
8+
import SwiftData
9+
import SwiftUI
10+
import PredicateView
11+
12+
struct PredicateDecodingDemoView: View {
13+
@Environment(\.modelContext) private var modelContext
14+
@State var predicate: Predicate<Item> = #Predicate<Item> {
15+
$0.title.localizedStandardContains("Item")
16+
}
17+
18+
@State private var savedPredicates: [Predicate<Item>] = []
19+
20+
var body: some View {
21+
VStack(alignment: .leading) {
22+
Text("This demo showcases the optional capability of decoding a pre-built externally supplied `Predicate` and populating the control's UI from it. Each individual expression type, including any custom expression view types, implements decoding logic for its represented `PredicateExpression`s if it wants to participate in this behavior.")
23+
24+
predicateView(for: $predicate)
25+
26+
Text("You can clone the predicate you've built above, which will populate a new instance of the `PredicateView` control in the group below.")
27+
28+
DisclosureGroup("Cloning") {
29+
VStack(alignment: .leading) {
30+
Button("New Clone") {
31+
savedPredicates.append(predicate)
32+
}
33+
34+
ForEach(Array($savedPredicates.enumerated()), id: \.offset) { index, $predicate in
35+
predicateView(for: $predicate)
36+
}
37+
}
38+
.padding(.vertical)
39+
.frame(maxWidth: .infinity, alignment: .leading)
40+
}
41+
42+
Table(items) {
43+
TableColumn("Title", value: \.title)
44+
TableColumn("Status", value: \.status.rawValue)
45+
TableColumn("Created") { value in
46+
Text(value.creationDate, style: .date)
47+
}
48+
TableColumn("Modified") { value in
49+
Text(value.modificationDate, style: .date)
50+
}
51+
}
52+
}
53+
.padding()
54+
}
55+
56+
private var items: [Item] {
57+
try! modelContext.fetch(.init(predicate: predicate))
58+
}
59+
60+
private func predicateView(for predicate: Binding<Predicate<Item>>) -> some View {
61+
ScrollView(.horizontal) {
62+
PredicateView(predicate: predicate, rowTemplates: [
63+
.init(keyPath: \.title, title: "Title"),
64+
.init(keyPath: \.creationDate, title: "Creation date"),
65+
.init(keyPath: \.modificationDate, title: "Modification date"),
66+
.init(StatusExpressionView.self),
67+
])
68+
}
69+
}
70+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
//
2+
// ReadOnlyDemoView.swift
3+
// PredicateViewExample
4+
//
5+
// Created by Phil Zakharchenko on 9/28/24.
6+
//
7+
8+
import SwiftData
9+
import SwiftUI
10+
import PredicateView
11+
12+
struct ReadOnlyDemoView: View {
13+
@Environment(\.modelContext) private var modelContext
14+
@State var predicate: Predicate<Item> = #Predicate<Item> {
15+
$0.title.localizedStandardContains("Item") && $0._status != "done"
16+
}
17+
18+
var body: some View {
19+
VStack(alignment: .leading) {
20+
predicateView(for: $predicate)
21+
.disabled(true)
22+
23+
Table(items) {
24+
TableColumn("Title", value: \.title)
25+
TableColumn("Status", value: \.status.rawValue)
26+
TableColumn("Created") { value in
27+
Text(value.creationDate, style: .date)
28+
}
29+
TableColumn("Modified") { value in
30+
Text(value.modificationDate, style: .date)
31+
}
32+
}
33+
}
34+
.padding()
35+
}
36+
37+
private var items: [Item] {
38+
try! modelContext.fetch(.init(predicate: predicate))
39+
}
40+
41+
private func predicateView(for predicate: Binding<Predicate<Item>>) -> some View {
42+
ScrollView(.horizontal) {
43+
PredicateView(predicate: predicate, rowTemplates: [
44+
.init(keyPath: \.title, title: "Title"),
45+
.init(keyPath: \.creationDate, title: "Creation date"),
46+
.init(keyPath: \.modificationDate, title: "Modification date"),
47+
.init(StatusExpressionView.self),
48+
])
49+
}
50+
}
51+
}

0 commit comments

Comments
 (0)