diff --git a/DependencyPropertyPrototype/.gitignore b/DependencyPropertyPrototype/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/DependencyPropertyPrototype/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/DependencyPropertyPrototype/Package.swift b/DependencyPropertyPrototype/Package.swift new file mode 100644 index 00000000..0811e40b --- /dev/null +++ b/DependencyPropertyPrototype/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "DependencyPropertyPrototype", + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "DependencyPropertyPrototype", + targets: ["DependencyPropertyPrototype"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "DependencyPropertyPrototype"), + .testTarget( + name: "DependencyPropertyPrototypeTests", + dependencies: ["DependencyPropertyPrototype"]), + ] +) diff --git a/DependencyPropertyPrototype/Readme.md b/DependencyPropertyPrototype/Readme.md new file mode 100644 index 00000000..5ea03d18 --- /dev/null +++ b/DependencyPropertyPrototype/Readme.md @@ -0,0 +1,47 @@ +# Dependency Property Prototype + +This project contains a prototype to show how we can model DependencyProperites after `Published` properties. + +Furthermore, we can also specially generate them in a manner which enforces type safety when using Styles/Setters. + +See the code in `Sources` dir for the API and explanation. + +See the Tests for use cases + +## Usage + +### Change notifications + +``` +let myDO = MyDO() +myDO.myProperty = "hello" +myDO.$myProperty.sink { + XCTAssertEqual($0, "hello") + XCTAssertEqual($1, "world") +} + +myDO.myProperty = "world" +``` + +### Style type enforcements + +``` +let style = Style(targetType: MyDO.self) { + Setter(.myProperty, "hi") + Setter(.myIntProperty, 2) +} + +``` + +## Open Questions + +The `Style` class has a type-erased base class `StyleBase` which is what the API would expose, however this type wouldn't be constructible. + +In theory, someone could still apply an incorrect `Style` to a framework element. For example, something like this is allowed: + +``` +let obj = MyDO() +obj.Style = Style(targetType.MyOtherDO) { ... } +``` + +Is there a way to enforce this? Should `Style` have an API called `applyTo` which takes the object it wants to be applied to? \ No newline at end of file diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/DependencyObject+Abi.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/DependencyObject+Abi.swift new file mode 100644 index 00000000..4cf27ce0 --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/DependencyObject+Abi.swift @@ -0,0 +1,9 @@ +// DOAbi is the +public class DOAbi { + func registerPropertyChanged(_ dp: DependencyPropertyHandle, _ handler: @escaping (Any?, Any?) -> Void) { + print("abi :registerPropertyChanged") + self.handler = handler + } + + var handler: ((Any, Any) -> Void)? +} \ No newline at end of file diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/FrameworkElement+ABI.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/FrameworkElement+ABI.swift new file mode 100644 index 00000000..195a61c4 --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/FrameworkElement+ABI.swift @@ -0,0 +1,11 @@ +class FrameworkElementABI: DOAbi { + private var styleProperty: StyleBase? + func setStyleProperty(_ value: StyleBase?) { + let oldValue = styleProperty + self.styleProperty = value + handler?(oldValue, value) + } + func getStyleProperty() -> StyleBase? { + return styleProperty + } +} \ No newline at end of file diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/MyDO+Abi.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/MyDO+Abi.swift new file mode 100644 index 00000000..95c1961d --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/MyDO+Abi.swift @@ -0,0 +1,23 @@ +class MyDOABI : FrameworkElementABI{ + private var myProperty:String = "" + func setMyProperty(_ value: String) { + let oldValue = myProperty + self.myProperty = value + handler?(oldValue, value) + } + func getMyProperty() -> String { + return myProperty + } + + private var myIntProperty:Int = 0 + func setMyIntProperty(_ value: Int) { + let oldValue = myIntProperty + self.myIntProperty = value + handler?(oldValue, value) + } + func getMyIntProperty() -> Int { + return myIntProperty + } + + +} \ No newline at end of file diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/MyDO2+Abi.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/MyDO2+Abi.swift new file mode 100644 index 00000000..dfde5991 --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/MyDO2+Abi.swift @@ -0,0 +1,12 @@ +class MyDO2ABI : MyDOABI{ + private var myBoolProperty:Bool = false + func setMyBoolProperty(_ value: Bool) { + let oldValue = myBoolProperty + self.myBoolProperty = value + handler?(oldValue, value) + } + func getMyBoolProperty() -> Bool { + return myBoolProperty + } + +} \ No newline at end of file diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/Setter+Abi.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/Setter+Abi.swift new file mode 100644 index 00000000..c55f41fb --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/Setter+Abi.swift @@ -0,0 +1,25 @@ +class SetterAbi : DOAbi{ + override init() { + self.property = DependencyPropertyHandle() + super.init() + } + private var property: DependencyPropertyHandle + func setProperty(_ value: DependencyPropertyHandle) { + let oldValue = property + self.property = value + handler?(oldValue, value) + } + func getProperty() -> DependencyPropertyHandle { + return property + } + + private var value: Any? + func setValue(_ value: Any?) { + //let oldValue = self.value + self.value = value + //handler?(oldValue, value) + } + func getValue() -> Any? { + return value + } +} \ No newline at end of file diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/Style+Abi.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/Style+Abi.swift new file mode 100644 index 00000000..e17dad82 --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/ABI/Style+Abi.swift @@ -0,0 +1,5 @@ +class StyleAbi : DOAbi{ + override init() { + super.init() + } +} diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/DependencyObject.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/DependencyObject.swift new file mode 100644 index 00000000..22dd4092 --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/DependencyObject.swift @@ -0,0 +1,15 @@ +// This prototype of DependencyObject is a "dummy" implementation that mimics how the swiftwinrt code gen +// works today. The public swift type wraps an internal type. The internal type is what talks to the actual +// ABI. + +open class DependencyObject { + + public init(fromABi: DOAbi) { + } + + func registerPropertyChanged(_ dp: DependencyPropertyHandle, _ handler: (Any?, Any?) -> Void) { + print("registerPropertyChanged") + } + + //@Styled var style: StyleBase? +} diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/DependencyProperty.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/DependencyProperty.swift new file mode 100644 index 00000000..0ec0bb31 --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/DependencyProperty.swift @@ -0,0 +1,49 @@ +// A "DependencyPropertyHandle" is the "true" DependencyProperty type that +// the WinUI system understands and uses +public class DependencyPropertyHandle {} + +// DependencyProperties are the "public" properties for a given DependencyObject +// and are strongly typed to the actual type of the property and who owns them +// this allows us to use the swift compiler to enforce type safety +public struct DependencyProperties { + let handle: DependencyPropertyHandle +} + +public protocol DependencyPropertyPublisher { + associatedtype Value + func sink(_ handler: @escaping (Value, Value) -> Void) + var property: DependencyPropertyHandle { get } +} + +public protocol DependencyPropertyProxy { + associatedtype Value + var set: (Value) -> Void { get } + var get: () -> Value { get } +} + +public protocol DependencyPropertyChangedProxy: DependencyPropertyPublisher, DependencyPropertyProxy{ +} + +// The DepdnencyProperty wrapper is given setters/getters since while it is possible to access the owning +// instance of the wrapper in the wrappedValue setter/getter, it is not possible to access the instance +// in the projectedValue. This means we can't properly subscribe to the property changed event on the +// object. It's for this reason that we have the DependencyPropertyProxy protocol instead of using KeyPaths as +// done here: https://www.swiftbysundell.com/articles/accessing-a-swift-property-wrappers-enclosing-instance/ +@propertyWrapper +public struct DependencyProperty { + public var wrappedValue: Value { + get { _get() } + set { _set(newValue) } + } + + private var _get: () -> Value + private var _set: (Value) -> Void + + init>(_ proxy: Proxy) { + self._get = proxy.get + self._set = proxy.set + self.projectedValue = proxy + } + + public let projectedValue: any DependencyPropertyPublisher +} diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/DependencyPropertyChangedProxyImpl.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/DependencyPropertyChangedProxyImpl.swift new file mode 100644 index 00000000..50602868 --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/DependencyPropertyChangedProxyImpl.swift @@ -0,0 +1,27 @@ +// implementation of the DependencyPropertyChangedProxy protocol. We require a separate implementation type +// because this type understands the ABI, whereas the generic DependencyPropertyChangedProxy just understands +// the value + +struct DependencyPropertyChangedProxyImpl: DependencyPropertyChangedProxy { + let property: DependencyPropertyHandle + let owner: Owner + + init(property: DependencyPropertyHandle, owner: Owner, set: @escaping (Owner, Value) -> Void, get: @escaping (Owner) -> Value) { + self.property = property + self.owner = owner + self.set = { value in + set(owner, value) + } + self.get = { + return get(owner) + } + } + let set: (Value) -> Void + let get: () -> Value + + func sink(_ handler: @escaping (Value, Value) -> Void) { + owner.registerPropertyChanged(property) { (oldValue, newValue) in + handler(oldValue as! Value, newValue as! Value) + } + } +} \ No newline at end of file diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/FrameworkElement.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/FrameworkElement.swift new file mode 100644 index 00000000..ec8dca9d --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/FrameworkElement.swift @@ -0,0 +1,4 @@ +public class FrameworkElement : DependencyObject { + + @Styled public var style: Style2? +} \ No newline at end of file diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/MyDO.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/MyDO.swift new file mode 100644 index 00000000..8227a362 --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/MyDO.swift @@ -0,0 +1,51 @@ +// A simple DependencyObject that has two properties. This would reflect the codegenerated to handle +// this type and properties associated with it + +public class MyDO: FrameworkElement { + + override init(fromABi: DOAbi) { + let abi = fromABi as! MyDOABI + self._abi = abi + + // We have to initialize the _myProperty PropertyWrapper in the initializer since it needs a reference to the ABI. + // This can't be done in the + self._myProperty = .init(MyDOProperties.myPropertyPublisher(abi)) + self._myIntProperty = .init(MyDOProperties.myIntPropertyPublisher(abi)) + super.init(fromABi: abi) + } + + public convenience init() { + let abi = MyDOABI() + self.init(fromABi: abi) + } + + private let _abi: MyDOABI + + @DependencyProperty var myProperty: String + @DependencyProperty var myIntProperty: Int +} + +fileprivate extension MyDO { + struct MyDOProperties { + static let myPropertyProperty = DependencyPropertyHandle() + static let myPropertyPublisher = { (owner: MyDOABI) in DependencyPropertyChangedProxyImpl( + property: myPropertyProperty, + owner: owner, + set: { $0.setMyProperty($1) }, + get: { $0.getMyProperty() } + )} + + static let myIntPropertyProperty = DependencyPropertyHandle() + static let myIntPropertyPublisher = { (owner: MyDOABI) in DependencyPropertyChangedProxyImpl( + property: myPropertyProperty, + owner: owner, + set: { $0.setMyIntProperty($1) }, + get: { $0.getMyIntProperty() } + )} + } +} + +public extension DependencyProperties where Type: MyDO { + static var myProperty: DependencyProperties { .init(handle: MyDO.MyDOProperties.myPropertyProperty) } + static var myIntProperty: DependencyProperties { .init(handle: MyDO.MyDOProperties.myIntPropertyProperty) } +} diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/MyDO2.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/MyDO2.swift new file mode 100644 index 00000000..2719ff5c --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/MyDO2.swift @@ -0,0 +1,40 @@ +// A simple DependencyObject that has two properties. This would reflect the codegenerated to handle +// this type and properties associated with it + +public class MyDO2: MyDO { + + override init(fromABi: DOAbi) { + let abi = fromABi as! MyDO2ABI + self._abi = abi + + // We have to initialize the _myProperty PropertyWrapper in the initializer since it needs a reference to the ABI. + // This can't be done in the + self._myBoolProperty = .init(MyDO2Properties.myBoolPropertyPublisher(abi)) + super.init(fromABi: abi) + } + + public convenience init() { + let abi = MyDO2ABI() + self.init(fromABi: abi) + } + + private let _abi: MyDO2ABI + + @DependencyProperty var myBoolProperty: Bool +} + +fileprivate extension MyDO2 { + struct MyDO2Properties { + static let myBoolPropertyProperty = DependencyPropertyHandle() + static let myBoolPropertyPublisher = { (owner: MyDO2ABI) in DependencyPropertyChangedProxyImpl( + property: myBoolPropertyProperty, + owner: owner, + set: { $0.setMyBoolProperty($1) }, + get: { $0.getMyBoolProperty() } + )} + } +} + +public extension DependencyProperties where Type: MyDO2 { + static var myBoolProperty: DependencyProperties { .init(handle: MyDO2.MyDO2Properties.myBoolPropertyProperty) } +} diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/MyOtherDO.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/MyOtherDO.swift new file mode 100644 index 00000000..f47428e1 --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/MyOtherDO.swift @@ -0,0 +1,50 @@ +// Another DO implementation to show how styles are enforced by the type system. This uses +// the same underlying ABI type for simplicity of the example. +public class MyOtherDO: DependencyObject { + + override init(fromABi: DOAbi) { + let abi = fromABi as! MyDOABI + self._abi = abi + + // We have to initialize the _myProperty PropertyWrapper in the initializer since it needs a reference to the ABI. + // This can't be done in the + self._myStringProperty = .init(MyOtherDOProperties.myPropertyPublisher(abi)) + self._myOtherIntProperty = .init(MyOtherDOProperties.myIntPropertyPublisher(abi)) + super.init(fromABi: abi) + } + + public convenience init() { + let abi = MyDOABI() + self.init(fromABi: abi) + } + + private let _abi: MyDOABI + + @DependencyProperty var myStringProperty: String + @DependencyProperty var myOtherIntProperty: Int +} + +fileprivate extension MyOtherDO { + struct MyOtherDOProperties { + static let myPropertyProperty = DependencyPropertyHandle() + static let myPropertyPublisher = { (owner: MyDOABI) in DependencyPropertyChangedProxyImpl( + property: myPropertyProperty, + owner: owner, + set: { $0.setMyProperty($1) }, + get: { $0.getMyProperty() } + )} + + static let myIntPropertyProperty = DependencyPropertyHandle() + static let myIntPropertyPublisher = { (owner: MyDOABI) in DependencyPropertyChangedProxyImpl( + property: myPropertyProperty, + owner: owner, + set: { $0.setMyIntProperty($1) }, + get: { $0.getMyIntProperty() } + )} + } +} + +public extension DependencyProperties where Type: MyOtherDO { + static var myStringProperty: DependencyProperties { .init(handle: MyOtherDO.MyOtherDOProperties.myPropertyProperty) } + static var myOtherIntProperty: DependencyProperties { .init(handle: MyOtherDO.MyOtherDOProperties.myIntPropertyProperty) } +} \ No newline at end of file diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/Setter.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/Setter.swift new file mode 100644 index 00000000..ab8dd23d --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/Setter.swift @@ -0,0 +1,16 @@ +// Like Style, Setters are going to be specially hand generated to apply to a specific type. Note +// that Setters don't partake in the DependencyProperties type sytem. It's not that they couldn't, +// but there is really no need for them to. +public class Setter : DependencyObject { + public convenience init(_ property: DependencyProperties, _ value: Value) { + self.init(fromABi: SetterAbi()) + self.property = property.handle + self.value = value + } + + override init(fromABi: DOAbi) { + super.init(fromABi: fromABi) + } + var property: DependencyPropertyHandle? + var value: Any? +} diff --git a/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/Style.swift b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/Style.swift new file mode 100644 index 00000000..3cb3f7ba --- /dev/null +++ b/DependencyPropertyPrototype/Sources/DependencyPropertyPrototype/Style.swift @@ -0,0 +1,50 @@ +// "Base" class which is type-erased and is the type that would be used at the API surface. It can't be constructed directly. +public class StyleBase: DependencyObject { + fileprivate init(fromABI: StyleAbi){ + super.init(fromABi: fromABI) + } +} + +// The Style class would be specially handgenerated to be applied to a specific type. +public class Style: StyleBase { + var targetType: AppliedTo.Type? + var setters: [Setter] = [] + + @resultBuilder + public enum SetterBuilder { + public static func buildBlock(_ setters: Setter...) -> [Setter] { + setters + } + } + + public convenience init(targetType: AppliedTo.Type, @SetterBuilder _ builder: () -> [Setter]) { + self.init(fromABI: StyleAbi()) + self.targetType = targetType + } + + public convenience init(targetType: AppliedTo.Type) { + self.init(fromABI: StyleAbi()) + self.targetType = targetType + } +} + +public class Style2 : Style { + +} + +@propertyWrapper +public struct StyleContainer { + public var wrappedValue: Style2? + + public init(wrappedValue: Style2?) { + self.wrappedValue = wrappedValue + } +} + +public protocol StyleContainerType where Self: FrameworkElement{ + typealias Styled = StyleContainer +} + +extension FrameworkElement: StyleContainerType { +} + diff --git a/DependencyPropertyPrototype/Tests/DependencyPropertyPrototypeTests.swift/DependencyPropertyPrototypeTests.swift b/DependencyPropertyPrototype/Tests/DependencyPropertyPrototypeTests.swift/DependencyPropertyPrototypeTests.swift new file mode 100644 index 00000000..aeb3e829 --- /dev/null +++ b/DependencyPropertyPrototype/Tests/DependencyPropertyPrototypeTests.swift/DependencyPropertyPrototypeTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import DependencyPropertyPrototype + +final class DependencyPropertyPrototypeTests: XCTestCase { + func testChangeNotification() throws { + let myDO = MyDO() + myDO.myProperty = "hello" + myDO.$myProperty.sink { + XCTAssertEqual($0, "hello") + XCTAssertEqual($1, "world") + } + + myDO.myProperty = "world" + } + + func testStyleAndSettersConstrainedToType() throws { + let style = Style(targetType: MyDO.self) { + Setter(.myProperty, "hi") + Setter(.myIntProperty, 2) + } + + /* + Commented out style to show that the compiler will properly enforce that the type of the setter matches the type of the property + let style2 = Style(targetType: MyDO.self) { + Setter(.myOtherIntProperty, 3) + } + The above code produces the following error: + + error: cannot convert value of type 'Setter' to expected argument type 'Setter' + Setter(.myOtherIntProperty, 3) + ^ + note: arguments to generic parameter 'AppliedTo' ('MyOtherDO' and 'MyDO') are expected to be equal + */ + + let myDO = MyDO() + myDO.style = Style(targetType: MyDO2.self) + } +}