Skip to content

Commit 8f2a823

Browse files
committed
Initial implementation
1 parent 1680c31 commit 8f2a823

19 files changed

Lines changed: 1053 additions & 1 deletion

Package.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// swift-tools-version: 5.10
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "PredicateView",
8+
platforms: [
9+
.macOS("14.0"),
10+
.iOS("17.0"),
11+
.tvOS("17.0"),
12+
.watchOS("10.0")
13+
],
14+
products: [
15+
.library(name: "PredicateView", targets: ["PredicateView"]),
16+
],
17+
targets: [
18+
.target(name: "PredicateView"),
19+
]
20+
)

README.md

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,67 @@
11
# PredicateView
2-
A SwiftUI control for editing Swift Predicates
2+
3+
A SwiftUI control for viewing and editing Swift Predicates
4+
5+
![PredicateView on macOS](Resources/MacDemo.png "PredicateView on macOS")
6+
7+
## Motivation
8+
9+
[`NSPredicateEditor`](https://developer.apple.com/documentation/appkit/nspredicateeditor) is amazing, but doesn't (and likely won't) have support for Foundation's new [Swift Predicates](https://forums.swift.org/t/pitch-swift-predicates/62000) feature ([proposal link](https://gist.github.com/jmschonfeld/6821392a968a1a1a42aba3c96d333239)).
10+
11+
In addition – not that it matters – as an AppKit control, `NSPredicateEditor` doesn't support other platforms.
12+
13+
This repo is an experiment on designing and implementing a cross-platform SwiftUI-native control for viewing and editing Swift Predicates. In addition, `PredicateView` has a more compact UI representation, especially when editing more complex predicates.
14+
15+
## Getting Started
16+
17+
A `PredicateView` can be initialized using a binding to a [`Predicate`](https://developer.apple.com/documentation/foundation/predicate) and a collection of row templates. Note: it purposefully uses the same terminology for row templates as `NSPredicateEditor`.
18+
19+
```swift
20+
/// A sample model type.
21+
struct Model: Identifiable {
22+
enum EmploymentStatus: String, CaseIterable, Codable {
23+
case employed
24+
case unemployed
25+
case selfEmployed
26+
case student
27+
case other
28+
}
29+
30+
let id = UUID()
31+
let firstName: String
32+
let age: Int
33+
let employmentStatus: EmploymentStatus
34+
let isRegistered: Bool
35+
}
36+
37+
/// A sample predicate.
38+
@State var predicate: Predicate<Model> = .true
39+
40+
/// A predicate view that supports filtering through first names, age, employment, and registration status.
41+
PredicateView(predicate: $predicate, rowTemplates: [
42+
.init(keyPath: \.firstName, title: "First Name"),
43+
.init(keyPath: \.age, title: "Age"),
44+
.init(keyPath: \.employmentStatus, title: "Employment Status"),
45+
.init(keyPath: \.isRegistered, title: "Registration Status")
46+
])
47+
```
48+
49+
See the built-in `PredicateDemoView` for a complete sample use case.
50+
51+
## Features
52+
53+
- [x] Easy setup
54+
- [x] Type safety
55+
- [x] Rich representations for supported data types
56+
57+
## Not Features
58+
59+
As an experimental control, `PredicateView` does not support the following capabilities. It would, however, be great to see them implemented in the future.
60+
61+
- [ ] Fully custom appearance and custom token views
62+
- [ ] A rich text experience using text attachments
63+
- [ ] Support for all built-in `PredicateExpression`s
64+
65+
## Compatibility
66+
67+
Compatibility matches that of the [Swift predicates](https://forums.swift.org/t/pitch-swift-predicates/62000) feature; namely macOS 14.0+, iOS 17.0+, watchOS 10.0+.

Resources/MacDemo.png

376 KB
Loading
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// CompoundAttribute.swift
3+
//
4+
//
5+
// Created by Phil Zakharchenko on 3/17/24.
6+
//
7+
8+
import Foundation
9+
10+
public struct CompoundAttribute<Expr>: OperatorContainer, Hashable where Expr: CompoundExpression {
11+
public var `operator`: Expr.Operator
12+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// ExpressionAttribute.swift
3+
// PredicateView
4+
//
5+
// Created by Phil Zakharchenko on 2/25/24.
6+
//
7+
8+
import Foundation
9+
10+
public struct ExpressionAttribute<Expr>: OperatorContainer, Hashable where Expr: SimpleExpression {
11+
public var `operator`: Expr.Operator
12+
public var value: Expr.Value
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// OperatorContainer.swift
3+
//
4+
//
5+
// Created by Phil Zakharchenko on 3/17/24.
6+
//
7+
8+
import Foundation
9+
10+
public protocol OperatorContainer: Equatable {
11+
associatedtype Expr: Expression
12+
var `operator`: Expr.Operator { get set }
13+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//
2+
// BoolExpression.swift
3+
// PredicateView
4+
//
5+
// Created by Phil Zakharchenko on 3/3/24.
6+
//
7+
8+
import SwiftUI
9+
10+
struct BoolExpression<Root>: SimpleExpression {
11+
typealias ExprView = BoolExpressionView<Root>
12+
13+
enum Operator: String, CaseIterable {
14+
case `is` = "is"
15+
case isNot = "is not"
16+
17+
var label: String {
18+
switch self {
19+
case .is: "Yes"
20+
case .isNot: "No"
21+
}
22+
}
23+
24+
var associatedValue: Bool {
25+
switch self {
26+
case .is: true
27+
case .isNot: false
28+
}
29+
}
30+
}
31+
32+
var id = UUID()
33+
let keyPath: KeyPath<Root, Bool>
34+
let title: String
35+
var attribute: ExpressionAttribute<Self> = .init(operator: .is, value: true)
36+
37+
func buildPredicate(using input: PredicateExpressions.Variable<Root>) -> (any StandardPredicateExpression<Bool>)? {
38+
switch attribute.operator {
39+
case .is:
40+
PredicateExpressions.Equal(
41+
lhs: PredicateExpressions.KeyPath(root: input, keyPath: keyPath),
42+
rhs: PredicateExpressions.Value(attribute.value)
43+
)
44+
case .isNot:
45+
PredicateExpressions.NotEqual(
46+
lhs: PredicateExpressions.KeyPath(root: input, keyPath: keyPath),
47+
rhs: PredicateExpressions.Value(attribute.value)
48+
)
49+
}
50+
}
51+
}
52+
53+
struct BoolExpressionView<Root>: ExpressionView {
54+
typealias Expression = BoolExpression<Root>
55+
56+
@Binding var expression: Expression
57+
58+
var body: some View {
59+
TokenView(Root.self, header: {
60+
Text("\(expression.title) \(expression.attribute.operator.rawValue)")
61+
}, content: {
62+
Picker("Value", selection: $expression.attribute.value) {
63+
ForEach(Expression.Operator.allCases, id: \.self) {
64+
Text($0.label)
65+
.tag($0.associatedValue)
66+
}
67+
}
68+
.pickerStyle(.segmented)
69+
.labelsHidden()
70+
}, menu: {
71+
expression.operatorPickerView(using: $expression.attribute)
72+
})
73+
}
74+
}
75+
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//
2+
// EnumExpression.swift
3+
// PredicateView
4+
//
5+
// Created by Phil Zakharchenko on 3/3/24.
6+
//
7+
8+
import SwiftUI
9+
10+
struct EnumExpression<Root, EnumType>: SimpleExpression where EnumType: CaseIterable & CustomStringConvertible & Codable & Hashable & Identifiable, EnumType.AllCases: RandomAccessCollection {
11+
typealias ExprView = EnumExpressionView
12+
13+
enum Operator: String, CaseIterable {
14+
case `is` = "is"
15+
case isNot = "is not"
16+
}
17+
18+
var id = UUID()
19+
let keyPath: KeyPath<Root, EnumType>
20+
let title: String
21+
var attribute: ExpressionAttribute<Self> = .init(operator: .is, value: EnumType.allCases.first!)
22+
23+
func buildPredicate(using input: PredicateExpressions.Variable<Root>) -> (any StandardPredicateExpression<Bool>)? {
24+
switch attribute.operator {
25+
case .is:
26+
PredicateExpressions.Equal(
27+
lhs: PredicateExpressions.KeyPath(root: input, keyPath: keyPath),
28+
rhs: PredicateExpressions.Value(attribute.value)
29+
)
30+
case .isNot:
31+
PredicateExpressions.NotEqual(
32+
lhs: PredicateExpressions.KeyPath(root: input, keyPath: keyPath),
33+
rhs: PredicateExpressions.Value(attribute.value)
34+
)
35+
}
36+
}
37+
38+
struct EnumExpressionView: ExpressionView {
39+
typealias Expression = EnumExpression<Root, EnumType>
40+
41+
@Binding var expression: Expression
42+
43+
var body: some View {
44+
TokenView(Root.self, header: {
45+
Text("\(expression.title) \(expression.attribute.operator.rawValue)")
46+
}, content: {
47+
Picker("Value", selection: $expression.attribute.value) {
48+
CustomStringConvertibleEnumPicker<EnumType>()
49+
}
50+
.labelsHidden()
51+
}, menu: {
52+
expression.operatorPickerView(using: $expression.attribute)
53+
})
54+
}
55+
}
56+
}
57+
58+
struct RawRepresentableEnumExpression<Root, EnumType>: SimpleExpression where EnumType: CaseIterable & RawRepresentable & Codable & Hashable, EnumType.AllCases: RandomAccessCollection, EnumType.RawValue: StringProtocol {
59+
typealias ExprView = EnumExpressionView
60+
61+
enum Operator: String, CaseIterable {
62+
case `is` = "is"
63+
case isNot = "is not"
64+
}
65+
66+
var id = UUID()
67+
let keyPath: KeyPath<Root, EnumType>
68+
let title: String
69+
var attribute: ExpressionAttribute<Self> = .init(operator: .is, value: EnumType.allCases.first!)
70+
71+
func buildPredicate(using input: PredicateExpressions.Variable<Root>) -> (any StandardPredicateExpression<Bool>)? {
72+
switch attribute.operator {
73+
case .is:
74+
PredicateExpressions.Equal(
75+
lhs: PredicateExpressions.KeyPath(root: input, keyPath: keyPath),
76+
rhs: PredicateExpressions.Value(attribute.value)
77+
)
78+
case .isNot:
79+
PredicateExpressions.NotEqual(
80+
lhs: PredicateExpressions.KeyPath(root: input, keyPath: keyPath),
81+
rhs: PredicateExpressions.Value(attribute.value)
82+
)
83+
}
84+
}
85+
86+
struct EnumExpressionView: ExpressionView {
87+
typealias Expression = RawRepresentableEnumExpression<Root, EnumType>
88+
89+
@Binding var expression: Expression
90+
91+
var body: some View {
92+
TokenView(Root.self, header: {
93+
Text("\(expression.title) \(expression.attribute.operator.rawValue)")
94+
}, content: {
95+
Picker("Value", selection: $expression.attribute.value) {
96+
RawRepresentableEnumPicker<EnumType>()
97+
}
98+
.labelsHidden()
99+
}, menu: {
100+
expression.operatorPickerView(using: $expression.attribute)
101+
})
102+
}
103+
}
104+
}
105+
106+
fileprivate struct CustomStringConvertibleEnumPicker<EnumType>: View where EnumType: CaseIterable & Hashable & CustomStringConvertible & Identifiable, EnumType.AllCases: RandomAccessCollection {
107+
var body: some View {
108+
ForEach(EnumType.allCases, id: \.self) { option in
109+
Text(option.description).tag(option)
110+
}
111+
}
112+
}
113+
114+
fileprivate struct RawRepresentableEnumPicker<EnumType>: View where EnumType: CaseIterable & Hashable & RawRepresentable, EnumType.AllCases: RandomAccessCollection, EnumType.RawValue: StringProtocol {
115+
var body: some View {
116+
ForEach(EnumType.allCases, id: \.self) { option in
117+
Text(option.rawValue).tag(option)
118+
}
119+
}
120+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//
2+
// ErasedExpression.swift
3+
// PredicateView
4+
//
5+
// Created by Phil Zakharchenko on 2/25/24.
6+
//
7+
8+
import Foundation
9+
10+
public struct ErasedExpression<Root>: Identifiable {
11+
public typealias ExpressionCompatible = Codable & Hashable
12+
public typealias NumericExpressionCompatible = Numeric & Strideable & ExpressionCompatible
13+
public typealias EnumExpressionCompatible = CaseIterable & CustomStringConvertible & Identifiable & ExpressionCompatible
14+
public typealias RawExpressionCompatible = CaseIterable & RawRepresentable & ExpressionCompatible
15+
16+
public var wrappedValue: any SimpleExpression<Root>
17+
public var id: UUID { wrappedValue.id }
18+
19+
public init(keyPath: KeyPath<Root, String>, title: String) {
20+
self.wrappedValue = StringExpression(keyPath: keyPath, title: title)
21+
}
22+
23+
public init<T>(keyPath: KeyPath<Root, T>, title: String) where T: NumericExpressionCompatible {
24+
self.wrappedValue = NumericExpression(keyPath: keyPath, title: title)
25+
}
26+
27+
public init<T>(keyPath: KeyPath<Root, T>, title: String) where T: EnumExpressionCompatible, T.AllCases: RandomAccessCollection {
28+
self.wrappedValue = EnumExpression(keyPath: keyPath, title: title)
29+
}
30+
31+
public init<T>(keyPath: KeyPath<Root, T>, title: String) where T: RawExpressionCompatible, T.AllCases: RandomAccessCollection, T.RawValue: StringProtocol {
32+
self.wrappedValue = RawRepresentableEnumExpression(keyPath: keyPath, title: title)
33+
}
34+
35+
public init(keyPath: KeyPath<Root, Bool>, title: String) {
36+
self.wrappedValue = BoolExpression(keyPath: keyPath, title: title)
37+
}
38+
39+
func copy() -> Self {
40+
var newInstance = self
41+
newInstance.wrappedValue.id = UUID()
42+
return newInstance
43+
}
44+
}

0 commit comments

Comments
 (0)