diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift new file mode 100644 index 000000000..1eef7a18d --- /dev/null +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -0,0 +1,126 @@ +// +// Element+HeaderFooter.swift +// BlueprintUILists +// +// Created by Kyle Van Essen on 7/24/22. +// + +import BlueprintUI +import ListableUI + + +// MARK: HeaderFooter / HeaderFooterContent Extensions + + +extension Element { + + /// Ensures that a well-formed error is presented when a non-Equatable or non-LayoutEquivalent element is provided. + @available(*, unavailable, message: "To be directly added to a List, an Element must conform to Equatable or LayoutEquivalent.") + public func listHeaderFooter( + background : @escaping () -> Element? = { nil }, + pressedBackground : @escaping () -> Element? = { nil }, + configure : (inout HeaderFooter>) -> () = { _ in } + ) -> HeaderFooter> { + fatalError() + } +} + +/// Ensures that the `Equatable` initializer for `WrappedHeaderFooterContent` is called. +extension Element where Self:Equatable { + + public func listHeaderFooter( + background : @escaping () -> Element? = { nil }, + pressedBackground : @escaping () -> Element? = { nil }, + configure : (inout HeaderFooter>) -> () = { _ in } + ) -> HeaderFooter> { + HeaderFooter( + WrappedHeaderFooterContent( + represented: self, + background: background, + pressedBackground: pressedBackground + ), + configure: configure + ) + } +} + + +/// Ensures that the `LayoutEquivalent` initializer for `WrappedHeaderFooterContent` is called. +extension Element where Self:LayoutEquivalent { + + @_disfavoredOverload + public func listHeaderFooter( + background : @escaping () -> Element? = { nil }, + pressedBackground : @escaping () -> Element? = { nil }, + configure : (inout HeaderFooter>) -> () = { _ in } + ) -> HeaderFooter> { + HeaderFooter( + WrappedHeaderFooterContent( + represented: self, + background: background, + pressedBackground: pressedBackground + ), + configure: configure + ) + } +} + + +public struct WrappedHeaderFooterContent : BlueprintHeaderFooterContent +{ + public let represented : ElementType + + private let isEquivalent : (Self, Self) -> Bool + + init( + represented : ElementType, + background : @escaping () -> Element?, + pressedBackground : @escaping () -> Element? + ) where ElementType:Equatable + { + self.represented = represented + + self.backgroundProvider = background + self.pressedBackgroundProvider = pressedBackground + + self.isEquivalent = { + $0.represented == $1.represented + } + } + + init( + represented : ElementType, + background : @escaping () -> Element?, + pressedBackground : @escaping () -> Element? + ) where ElementType:LayoutEquivalent + { + self.represented = represented + + self.backgroundProvider = background + self.pressedBackgroundProvider = pressedBackground + + self.isEquivalent = { + $0.represented.isEquivalent(to: $1.represented) + } + } + + public func isEquivalent(to other: Self) -> Bool { + isEquivalent(self, other) + } + + public var elementRepresentation: Element { + represented + } + + var backgroundProvider : () -> Element? = { nil } + + public var background: Element? { + backgroundProvider() + } + + var pressedBackgroundProvider : () -> Element? = { nil } + + public var pressedBackground: Element? { + pressedBackgroundProvider() + } +} diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift new file mode 100644 index 000000000..36dfcdd90 --- /dev/null +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -0,0 +1,149 @@ +// +// Element+Item.swift +// BlueprintUILists +// +// Created by Kyle Van Essen on 7/24/22. +// + +import BlueprintUI +import ListableUI + + +// MARK: Item / ItemContent Extensions + + +extension Element { + + /// Ensures that a well-formed error is presented when a non-Equatable or non-LayoutEquivalent element is provided. + @available(*, unavailable, message: "To be directly added to a List, an Element must conform to Equatable or LayoutEquivalent.") + public func listItem( + id : AnyHashable? = nil, + selection: ItemSelectionStyle = .notSelectable, + background : @escaping (ApplyItemContentInfo) -> Element? = { _ in nil }, + selectedBackground : @escaping (ApplyItemContentInfo) -> Element? = { _ in nil }, + configure : (inout Item>) -> () = { _ in } + ) -> Item> { + fatalError() + } +} + + +/// Ensures that the `Equatable` initializer for `WrappedElementContent` is called. +extension Element where Self:Equatable { + + public func listItem( + id : AnyHashable? = nil, + selection: ItemSelectionStyle = .notSelectable, + background : @escaping (ApplyItemContentInfo) -> Element? = { _ in nil }, + selectedBackground : @escaping (ApplyItemContentInfo) -> Element? = { _ in nil }, + configure : (inout Item>) -> () = { _ in } + ) -> Item> { + Item( + WrappedElementContent( + identifierValue: id, + represented: self, + background: background, + selectedBackground: selectedBackground + ), + configure: { + $0.selectionStyle = selection + + configure(&$0) + } + ) + } +} + + +/// Ensures that the `LayoutEquivalent` initializer for `WrappedElementContent` is called. +extension Element where Self:LayoutEquivalent { + + @_disfavoredOverload + public func listItem( + id : AnyHashable? = nil, + selection: ItemSelectionStyle = .notSelectable, + background : @escaping (ApplyItemContentInfo) -> Element? = { _ in nil }, + selectedBackground : @escaping (ApplyItemContentInfo) -> Element? = { _ in nil }, + configure : (inout Item>) -> () = { _ in } + ) -> Item> { + Item( + WrappedElementContent( + identifierValue: id, + represented: self, + background: background, + selectedBackground: selectedBackground + ), + configure: { + $0.selectionStyle = selection + + configure(&$0) + } + ) + } +} + + +public struct WrappedElementContent : BlueprintItemContent +{ + public let identifierValue: AnyHashable? + + public let represented : ElementType + + private let isEquivalent : (Self, Self) -> Bool + + init( + identifierValue: AnyHashable?, + represented: ElementType, + background : @escaping (ApplyItemContentInfo) -> Element?, + selectedBackground : @escaping (ApplyItemContentInfo) -> Element? + ) where ElementType:Equatable + { + self.represented = represented + self.identifierValue = identifierValue + + self.backgroundProvider = background + self.selectedBackgroundProvider = selectedBackground + + self.isEquivalent = { + $0.represented == $1.represented + } + } + + init( + identifierValue: AnyHashable?, + represented: ElementType, + background : @escaping (ApplyItemContentInfo) -> Element?, + selectedBackground : @escaping (ApplyItemContentInfo) -> Element? + ) where ElementType:LayoutEquivalent + { + self.represented = represented + self.identifierValue = identifierValue + + self.backgroundProvider = background + self.selectedBackgroundProvider = selectedBackground + + self.isEquivalent = { + $0.represented.isEquivalent(to: $1.represented) + } + } + + public func isEquivalent(to other: Self) -> Bool { + isEquivalent(self, other) + } + + public func element(with info: ApplyItemContentInfo) -> Element { + represented + } + + var backgroundProvider: (ApplyItemContentInfo) -> Element? + + public func backgroundElement(with info: ApplyItemContentInfo) -> Element? { + backgroundProvider(info) + } + + var selectedBackgroundProvider: (ApplyItemContentInfo) -> Element? + + public func selectedBackgroundElement(with info: ApplyItemContentInfo) -> Element? { + selectedBackgroundProvider(info) + } +} diff --git a/BlueprintUILists/Sources/HeaderFooter.swift b/BlueprintUILists/Sources/ElementHeaderFooter.swift similarity index 95% rename from BlueprintUILists/Sources/HeaderFooter.swift rename to BlueprintUILists/Sources/ElementHeaderFooter.swift index b255c14f7..e184ad1e6 100644 --- a/BlueprintUILists/Sources/HeaderFooter.swift +++ b/BlueprintUILists/Sources/ElementHeaderFooter.swift @@ -1,5 +1,5 @@ // -// HeaderFooter.swift +// ElementHeaderFooter.swift // BlueprintUILists // // Created by Kyle Van Essen on 10/9/20. @@ -9,6 +9,8 @@ import ListableUI import BlueprintUI +/// +/// ⚠️ This method is soft-deprecated! Consider using `myElement.listHeaderFooter(...)` instead. /// /// Provides a way to create a `HeaderFooter` for your Blueprint elements without /// requiring the creation of a new `BlueprintHeaderFooterContent` struct. @@ -62,6 +64,8 @@ public func ElementHeaderFooter( ) } +/// +/// ⚠️ This method is soft-deprecated! Consider using `myElement.listHeaderFooter(...)` instead. /// /// Provides a way to create a `HeaderFooter` for your Blueprint elements without /// requiring the creation of a new `BlueprintHeaderFooterContent` struct. diff --git a/BlueprintUILists/Sources/Item.swift b/BlueprintUILists/Sources/ElementItem.swift similarity index 96% rename from BlueprintUILists/Sources/Item.swift rename to BlueprintUILists/Sources/ElementItem.swift index 80aa58a37..c1cf9a0de 100644 --- a/BlueprintUILists/Sources/Item.swift +++ b/BlueprintUILists/Sources/ElementItem.swift @@ -1,5 +1,5 @@ // -// Item.swift +// ElementItem.swift // BlueprintUILists // // Created by Kyle Van Essen on 9/10/20. @@ -9,6 +9,8 @@ import ListableUI import BlueprintUI +/// +/// ⚠️ This method is soft-deprecated! Consider using `myElement.listItem(...)` instead. /// /// Provides a way to create an `Item` for your Blueprint elements without /// requiring the creation of a new `BlueprintItemContent` struct. @@ -68,6 +70,8 @@ public func ElementItem( /// +/// ⚠️ This method is soft-deprecated! Consider using `myElement.listItem(...)` instead. +/// /// Provides a way to create an `Item` for your Blueprint elements without /// requiring the creation of a new `BlueprintItemContent` struct. /// diff --git a/BlueprintUILists/Sources/List.swift b/BlueprintUILists/Sources/List.swift index 1d755de93..246a045e5 100644 --- a/BlueprintUILists/Sources/List.swift +++ b/BlueprintUILists/Sources/List.swift @@ -59,7 +59,7 @@ public struct List : Element // // MARK: Initialization // - + /// Create a new list, configured with the provided properties, /// configured with the provided `ListProperties` builder. public init( @@ -76,13 +76,26 @@ public struct List : Element public init( measurement : List.Measurement = .fillParent, configure : ListProperties.Configure = { _ in }, - @ListableBuilder
sections : () -> [Section] + @ListableArrayBuilder
sections : () -> [Section], + @AnyHeaderFooterBuilder containerHeader : () -> AnyHeaderFooterConvertible? = { nil }, + @AnyHeaderFooterBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, + @AnyHeaderFooterBuilder footer : () -> AnyHeaderFooterConvertible? = { nil }, + @AnyHeaderFooterBuilder overscrollFooter : () -> AnyHeaderFooterConvertible? = { nil } ) { self.measurement = measurement - self.properties = .default(with: configure) + var properties = ListProperties.default { + $0.sections = sections() + + $0.containerHeader = containerHeader() + $0.header = header() + $0.footer = footer() + $0.overscrollFooter = overscrollFooter() + } + + configure(&properties) - self.properties.sections += sections() + self.properties = properties } // diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift new file mode 100644 index 000000000..5d46ba8a1 --- /dev/null +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -0,0 +1,88 @@ +// +// ListableArrayBuilder+Element.swift +// BlueprintUILists +// +// Created by Kyle Van Essen on 7/24/22. +// + +import BlueprintUI +import ListableUI + + +/// Adds `Element` support when building `AnyItemConvertible` arrays, which allows: +/// +/// ```swift +/// Section("3") { section in +/// TestContent1() // An ItemContent +/// +/// Element1() // An Element +/// Element2() // An Element +/// } +/// ``` +public extension ListableArrayBuilder where ContentType == AnyItemConvertible { + + /// Ensures that a well-formed error is presented when a non-Equatable or non-LayoutEquivalent element is provided. + @available(*, unavailable, message: "To be directly added to a List, an Element must conform to Equatable or LayoutEquivalent.") + static func buildExpression(_ element: ElementType) -> Component { + fatalError() + } + + /// Ensures that the `Equatable`version of `.listItem()` is called. + static func buildExpression(_ element: ElementType) -> Component where ElementType:Equatable { + [element.listItem()] + } + + /// Ensures that the `LayoutEquivalent`version of `.listItem()` is called. + @_disfavoredOverload + static func buildExpression(_ element: ElementType) -> Component where ElementType:LayoutEquivalent { + [element.listItem()] + } + + @available(*, deprecated, message: "Cannot add a ListElementNonConvertible to a list. See the type's `listElementNonConvertibleFatal` implementation for the correct type to use instead.") + static func buildExpression(_ element: ElementType) -> Component where ElementType:ListElementNonConvertible { + element.listElementNonConvertibleFatal() + } +} + + +public extension ListableOptionalBuilder where ContentType == AnyHeaderFooterConvertible { + + /// Ensures that a well-formed error is presented when a non-Equatable or non-LayoutEquivalent element is provided. + @available(*, unavailable, message: "To be directly added to a List, an Element must conform to Equatable or LayoutEquivalent.") + static func buildBlock(_ element: ElementType) -> ContentType { + fatalError() + } + + /// Ensures that the `Equatable`version of `.listHeaderFooter()` is called. + static func buildBlock(_ element: ElementType) -> ContentType where ElementType:Equatable { + return element.listHeaderFooter() + } + + /// Ensures that the `LayoutEquivalent`version of `.listHeaderFooter()` is called. + @_disfavoredOverload + static func buildBlock(_ element: ElementType) -> ContentType where ElementType:LayoutEquivalent { + return element.listHeaderFooter() + } + + @available(*, deprecated, message: "Cannot add a ListElementNonConvertible to a list. See the type's `listElementNonConvertibleFatal` implementation for the correct type to use instead.") + static func buildBlock(_ element: ElementType) -> ContentType where ElementType:ListElementNonConvertible { + element.listElementNonConvertibleFatal() + } +} + + +/// Conform to this protocol if you have an `Element` which should not be implicitly converted into an `Item` or `HeaderFooter`. +public protocol ListElementNonConvertible { + + /// Implement this method to provide a more specific error for why the element + /// cannot be implicitly converted to an `Item` or `HeaderFooter`. + /// + /// ``` + /// func listElementNonConvertibleFatal() -> Never { + /// fatalError( + /// "`MarketRow` should not be directly used within a list. Please use `MarketListRow` instead." + /// ) + /// } + /// ``` + func listElementNonConvertibleFatal() -> Never +} diff --git a/BlueprintUILists/Sources/Section+Element.swift b/BlueprintUILists/Sources/Section+Element.swift new file mode 100644 index 000000000..4e8e1c608 --- /dev/null +++ b/BlueprintUILists/Sources/Section+Element.swift @@ -0,0 +1,67 @@ +// +// Section+Element.swift +// BlueprintUILists +// +// Created by Kyle Van Essen on 7/24/22. +// + +import BlueprintUI +import ListableUI + + +extension Section { + + /// Adds `Element` support when building a `Section`. + /// + /// ```swift + /// Section("id") { section in + /// section.add(Element1()) + /// section.add(Element2()) + /// } + /// ``` + public mutating func add(_ element : ElementType) where ElementType:Equatable + { + self.items.append(element.listItem()) + } + + /// Adds `Element` support when building a `Section`. + /// + /// ```swift + /// Section("id") { section in + /// section.add(Element1()) + /// section.add(Element2()) + /// } + /// ``` + @_disfavoredOverload + public mutating func add(_ element : ElementType) where ElementType:LayoutEquivalent + { + self.items.append(element.listItem()) + } + + /// Adds `Element` support when building a `Section`. + /// + /// ```swift + /// Section("id") { section in + /// section += Element1() + /// section += Element2() + /// } + /// ``` + public static func += (lhs : inout Section, rhs : ElementType) where ElementType:Equatable + { + lhs.add(rhs) + } + + /// Adds `Element` support when building a `Section`. + /// + /// ```swift + /// Section("id") { section in + /// section += Element1() + /// section += Element2() + /// } + /// ``` + @_disfavoredOverload + public static func += (lhs : inout Section, rhs : ElementType) where ElementType:LayoutEquivalent + { + lhs.add(rhs) + } +} diff --git a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift new file mode 100644 index 000000000..981359cef --- /dev/null +++ b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift @@ -0,0 +1,270 @@ +// +// ListableBuilder+ElementTests.swift +// BlueprintUILists +// +// Created by Kyle Van Essen on 7/24/22. +// + +import BlueprintUILists +import XCTest + + +class ListableArrayBuilder_Element_Tests : XCTestCase { + + func test_builders() { + + // Make sure the various result builder methods + // are present such that various control flow statements still compile. + + let aBool = Bool("true")! + + _ = Section("1") { + + if aBool { + TestContent1() + } else { + Element1() + } + + if aBool { + Element1() + } else { + Element2() + } + + if #available(iOS 11.0, *) { + Element1() + } else { + Element2() + } + } + + // Make sure building happens how we would expect. + + let list = List { + Section("1") { + TestContent1() + TestContent1() + TestContent2() + + Element1() + Element2() + } header: { + Element1() + } footer: { + Element2().listHeaderFooter() + } + + Section("2") { section in + section += TestContent1() + section += TestContent2() + + section += Element1() + section += Element2() + } + + Section("3") { section in + section.add { + TestContent1() + + Element1() + Element2() + } + } + } + + XCTAssertEqual(list.properties.content.sections.count, 3) + + XCTAssertEqual(list.properties.content.sections[0].count, 5) + XCTAssertEqual(list.properties.content.sections[1].count, 4) + XCTAssertEqual(list.properties.content.sections[2].count, 3) + } +} + + +class ListableOptionalBuilder_Element_Tests : XCTestCase { + + func test_item_default_implementation_resolution() { + + var callCount : Int = 0 + + let sections : [Section] = [ + Section("1") { + EquatableElement { callCount += 1 } + EquatableElement { callCount += 1 }.listItem() + EquivalentElement { callCount += 1 } + EquivalentElement { callCount += 1 }.listItem() + }, + + Section("1") { section in + section += EquatableElement { callCount += 1 } + section.add(EquatableElement { callCount += 1 }.listItem()) + section += EquivalentElement { callCount += 1 } + section.add(EquivalentElement { callCount += 1 }.listItem()) + }, + + Section("1") { section in + section.add { + EquatableElement { callCount += 1 } + EquatableElement { callCount += 1 }.listItem() + EquivalentElement { callCount += 1 } + EquivalentElement { callCount += 1 }.listItem() + } + } + ] + + for section in sections { + + callCount = 0 + + let equatableItem1 = section.items[0] + let equatableItem2 = section.items[1] + let equivalentItem1 = section.items[2] + let equivalentItem2 = section.items[3] + + XCTAssertTrue(equatableItem1.anyIsEquivalent(to: equatableItem1)) + XCTAssertEqual(callCount, 1) + + XCTAssertTrue(equatableItem2.anyIsEquivalent(to: equatableItem2)) + XCTAssertEqual(callCount, 2) + + XCTAssertTrue(equivalentItem1.anyIsEquivalent(to: equivalentItem1)) + XCTAssertEqual(callCount, 3) + + XCTAssertTrue(equivalentItem2.anyIsEquivalent(to: equivalentItem2)) + XCTAssertEqual(callCount, 4) + } + } + + func test_headerfooter_default_implementation_resolution() { + + var callCount : Int = 0 + + let equatableSection = Section("1") { + Element1() + } header: { + EquatableElement { callCount += 1 } + } footer: { + EquatableElement { callCount += 1 }.listHeaderFooter() + } + + let equivalentSection = Section("1") { + Element1() + } header: { + EquivalentElement { callCount += 1 } + } footer: { + EquivalentElement { callCount += 1 }.listHeaderFooter() + } + + let equatableItem1 = equatableSection.header!.asAnyHeaderFooter() + let equatableItem2 = equatableSection.footer!.asAnyHeaderFooter() + let equivalentItem1 = equivalentSection.header!.asAnyHeaderFooter() + let equivalentItem2 = equivalentSection.footer!.asAnyHeaderFooter() + + XCTAssertTrue(equatableItem1.anyIsEquivalent(to: equatableItem1)) + XCTAssertEqual(callCount, 1) + + XCTAssertTrue(equatableItem2.anyIsEquivalent(to: equatableItem2)) + XCTAssertEqual(callCount, 2) + + XCTAssertTrue(equivalentItem1.anyIsEquivalent(to: equivalentItem1)) + XCTAssertEqual(callCount, 3) + + XCTAssertTrue(equivalentItem2.anyIsEquivalent(to: equivalentItem2)) + XCTAssertEqual(callCount, 4) + } +} + + +fileprivate struct NonConvertibleElement : ProxyElement, ListElementNonConvertible { + + var elementRepresentation: Element { + Empty() + } + + func listElementNonConvertibleFatal() -> Never { + fatalError() + } +} + + +fileprivate struct NonIncludableElement : ProxyElement { + + var elementRepresentation: Element { + Empty() + } +} + + +fileprivate struct Element1 : ProxyElement, Equatable, LayoutEquivalent { + + var elementRepresentation: Element { + Empty() + } +} + + +fileprivate struct Element2 : ProxyElement, Equatable, LayoutEquivalent { + + var elementRepresentation: Element { + Empty() + } +} + + +fileprivate struct EquatableElement : ProxyElement, Equatable { + + var calledEqual : () -> () + + var elementRepresentation: Element { + Empty() + } + + static func == (lhs : Self, rhs : Self) -> Bool { + lhs.calledEqual() + return true + } +} + + +fileprivate struct EquivalentElement : ProxyElement, LayoutEquivalent { + + var calledIsEquivalent : () -> () + + var elementRepresentation: Element { + Empty() + } + + func isEquivalent(to other: EquivalentElement) -> Bool { + calledIsEquivalent() + return true + } +} + + +fileprivate struct TestContent1 : BlueprintItemContent, Equatable { + + var identifierValue: String { + "1" + } + + func element(with info: ApplyItemContentInfo) -> Element { + Empty() + } +} + + +fileprivate struct TestContent2 : BlueprintItemContent, LayoutEquivalent { + + func isEquivalent(to other: TestContent2) -> Bool { + true + } + + var identifierValue: String { + "1" + } + + func element(with info: ApplyItemContentInfo) -> Element { + Empty() + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f0059789..1651784f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,49 @@ ### Added +- Adding Blueprint Elements to your list content has become easier: Just add them directly! Elements just need to to conform to `Equatable` or `LayoutEquivalent`. If you need more advanced behaviors such as backgrounds, etc, you are encouraged to continue to create content types which conforms `BlueprintItemContent` or `BlueprintHeaderFooterContent` . The `ElementItem` and `ElementHeaderFooter` APIs will be deprecated in a future release, and are now soft-deprecated. + + ```swift + Section("an id") { + MyContent() // Regular ItemContent + + MyElement() // A Blueprint Element + AnotherElement() // A Blueprint Element + AnotherElement() + .listItem(id: "my-specified-id") { item in + item.insertAndRemoveAnimations = .scaleUp + } + } + ``` + +- Added `ListableOptionalBuilder`, a result builder for single-value results. This is used for header and footer builders. + +- Introduced `KeyPathLayoutEquivalent`, an easier way to write `isEquivalent` implementations using just key paths: + + ```swift + struct MyValue : KeyPathLayoutEquivalent { + + var name : String + var age : Int + var birthdate : Date + var nonCompared : Bool + + static var isEquivalent: KeyPaths { + \.name + \.age + \.birthdate + } + } + ``` + ### Removed ### Changed +- Definition of `isEquivalent(to:)` has been moved to `LayoutEquivalent`. + +- The `ListableBuilder` result builder is now `ListableArrayBuilder`. + ### Misc ### Internal diff --git a/Demo/Sources/Demos/Demo Screens/InvoicesPaymentScheduleDemoViewController.swift b/Demo/Sources/Demos/Demo Screens/InvoicesPaymentScheduleDemoViewController.swift index 653851ed3..3aae7c38f 100644 --- a/Demo/Sources/Demos/Demo Screens/InvoicesPaymentScheduleDemoViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/InvoicesPaymentScheduleDemoViewController.swift @@ -244,7 +244,7 @@ fileprivate struct ViewData : Equatable } -fileprivate struct ToggleRow : BlueprintItemContent +fileprivate struct ToggleRow : BlueprintItemContent, KeyPathLayoutEquivalent { var content : Content var onToggle : (Bool) -> () @@ -273,8 +273,8 @@ fileprivate struct ToggleRow : BlueprintItemContent self.content.text } - func isEquivalent(to other: ToggleRow) -> Bool { - self.content == other.content + static var isEquivalent: KeyPaths { + \.content } } @@ -324,13 +324,12 @@ fileprivate struct SegmentedControlRow : BlueprintItemContent self.id } - func isEquivalent(to other: SegmentedControlRow) -> Bool - { + func isEquivalent(to other: SegmentedControlRow) -> Bool { true } } -fileprivate struct AmountRow : BlueprintItemContent +fileprivate struct AmountRow : BlueprintItemContent, KeyPathLayoutEquivalent { var content : Content @@ -389,14 +388,12 @@ fileprivate struct AmountRow : BlueprintItemContent self.content.title } - func isEquivalent(to other: AmountRow) -> Bool - { - return self.content == other.content + static var isEquivalent: KeyPaths { + \.content } - } -fileprivate struct ButtonRow : BlueprintItemContent +fileprivate struct ButtonRow : BlueprintItemContent, KeyPathLayoutEquivalent { var text : String var onTap : () -> () @@ -410,8 +407,7 @@ fileprivate struct ButtonRow : BlueprintItemContent self.text } - func isEquivalent(to other: ButtonRow) -> Bool - { - return self.text == other.text + static var isEquivalent: KeyPaths { + \.text } } diff --git a/Demo/Sources/Demos/Demo Screens/ItemizationEditorViewController.swift b/Demo/Sources/Demos/Demo Screens/ItemizationEditorViewController.swift index feb0f0398..a19851d00 100644 --- a/Demo/Sources/Demos/Demo Screens/ItemizationEditorViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/ItemizationEditorViewController.swift @@ -239,7 +239,7 @@ struct ChoiceItem : BlueprintItemContent, Equatable } } -struct ToggleItem : BlueprintItemContent +struct ToggleItem : BlueprintItemContent, KeyPathLayoutEquivalent { var content : Content @@ -253,9 +253,8 @@ struct ToggleItem : BlueprintItemContent var onToggle : (Bool) -> () - func isEquivalent(to other: ToggleItem) -> Bool - { - return self.content == other.content + static var isEquivalent: KeyPaths { + \.content } var identifierValue: String { diff --git a/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift b/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift index 2c690f2d7..d087283bb 100644 --- a/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift @@ -262,7 +262,7 @@ fileprivate struct EmptyRow : BlueprintItemContent, Equatable { } } -fileprivate struct PaymentTypeRow : BlueprintItemContent { +fileprivate struct PaymentTypeRow : BlueprintItemContent, KeyPathLayoutEquivalent { var type : PaymentType @@ -315,8 +315,8 @@ fileprivate struct PaymentTypeRow : BlueprintItemContent { ) } - func isEquivalent(to other: PaymentTypeRow) -> Bool { - self.type == other.type + static var isEquivalent: KeyPaths { + \.type } } diff --git a/Demo/Sources/Demos/Demo Screens/SearchableDictionaryViewController.swift b/Demo/Sources/Demos/Demo Screens/SearchableDictionaryViewController.swift index 3cd12f637..16c940f5c 100644 --- a/Demo/Sources/Demos/Demo Screens/SearchableDictionaryViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/SearchableDictionaryViewController.swift @@ -143,7 +143,7 @@ final public class SearchableDictionaryViewController : UIViewController } } -fileprivate struct SearchBarElement : ItemContent +fileprivate struct SearchBarElement : ItemContent, KeyPathLayoutEquivalent { var text : String @@ -161,8 +161,8 @@ fileprivate struct SearchBarElement : ItemContent views.content.text = self.text } - func isEquivalent(to other: SearchBarElement) -> Bool { - return self.text == other.text + static var isEquivalent: KeyPaths { + \.text } typealias ContentView = SearchBar diff --git a/ListableUI/Sources/HeaderFooter/AnyHeaderFooterConvertible.swift b/ListableUI/Sources/HeaderFooter/AnyHeaderFooterConvertible.swift index 289e91d0b..8a4bbb5e0 100644 --- a/ListableUI/Sources/HeaderFooter/AnyHeaderFooterConvertible.swift +++ b/ListableUI/Sources/HeaderFooter/AnyHeaderFooterConvertible.swift @@ -39,3 +39,5 @@ public protocol AnyHeaderFooterConvertible { } +/// A result builder that creates and returns a header or footer convertible value. +public typealias AnyHeaderFooterBuilder = ListableOptionalBuilder diff --git a/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift b/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift index a312d6032..b2250cda8 100644 --- a/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift +++ b/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift @@ -44,14 +44,8 @@ public typealias FooterContent = HeaderFooterContent /// z-Index 2) `PressedBackgroundView` (Only if the header/footer is pressed, eg if the wrapping `HeaderFooter` has an `onTap` handler.) /// z-Index 1) `BackgroundView` /// -public protocol HeaderFooterContent : AnyHeaderFooterConvertible +public protocol HeaderFooterContent : LayoutEquivalent, AnyHeaderFooterConvertible { - // - // MARK: Tracking Changes - // - - func isEquivalent(to other : Self) -> Bool - // // MARK: Default Properties // @@ -215,15 +209,8 @@ public extension HeaderFooterContent { func asAnyHeaderFooter() -> AnyHeaderFooter { HeaderFooter(self) } -} - - -public extension HeaderFooterContent where Self:Equatable -{ - /// If your `HeaderFooterContent` is `Equatable`, `isEquivalent` is based on the `Equatable` implementation. - func isEquivalent(to other : Self) -> Bool { - self == other - } + + } diff --git a/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift b/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift new file mode 100644 index 000000000..dceb9d453 --- /dev/null +++ b/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift @@ -0,0 +1,167 @@ +// +// KeyPathLayoutEquivalent.swift +// ListableUI +// +// Created by Kyle Van Essen on 5/24/23. +// + +import Foundation + + +/// Used by the list to determine when the content of content has changed; in order to +/// remeasure the content and re-layout the list. +/// +/// ## Note +/// If you conform to `Equatable`, your value will receive `LayoutEquivalent` +/// conformance for free. +public protocol KeyPathLayoutEquivalent : LayoutEquivalent { + + typealias KeyPaths = [KeyPathLayoutEquivalentKeyPath] + typealias Builder = KeyPathLayoutEquivalentBuilder + + /// + /// Used by the list to determine when the content of content has changed; in order to + /// remeasure the content and re-layout the list. + /// + /// You should return the `KeyPaths` from this method that affect visual appearance + /// or layout (and in particular, sizing) changes. + /// + /// When the values from these `KeyPaths` are not equivalent, it will invalidate + /// any cached sizing it has stored for the content, and re-measure + re-layout the content. + /// + /// ## ⚠️ Important + /// `isEquivalent` is **not** an identifier check. That is what the `identifierValue` + /// on your `ItemContent` is for. It is to determine when content has meaningfully changed. + /// + /// ## 🤔 Examples & How To + /// + /// ```swift + /// struct MyItemContent : ItemContent, Equatable { + /// + /// var identifierValue : UUID + /// var title : String + /// var detail : String + /// var theme : MyTheme + /// var onTapDetail : () -> () + /// + /// static var isEquivalent : KeyPaths { + /// // 🚫 Missing checks for title and detail. + /// // If they change, they likely affect sizing, + /// // which would result in incorrect item sizing. + /// + /// \.theme + /// } + /// + /// static var isEquivalent : KeyPaths { + /// // 🚫 Missing check for theme. + /// // If the theme changed; its likely that the device's + /// // accessibility settings changed; dark mode was enabled, + /// // etc. All of these can affect the appearance or sizing + /// // of the item. + /// + /// \.title + /// \.detail + /// } + /// + /// static var isEquivalent : KeyPaths { + /// // ✅ Checking all parameters which can affect appearance + layout. + /// // 💡 Not checking identifierValue or onTapDetail, since they + /// // do not affect appearance + layout. + /// + /// \.theme + /// \.title + /// \.detail + /// } + /// } + /// + /// struct MyItemContent : ItemContent, Equatable { + /// // ✅ Nothing else needed! + /// // `Equatable` conformance provides `isEquivalent(to:) for free!` + /// } + /// ``` + /// + /// ## Note + /// If your ``ItemContent`` conforms to ``Equatable``, there is a default + /// implementation of this method which simply returns `self == other`. + /// + @Builder static var isEquivalent : KeyPaths { get } +} + + +fileprivate var cachedIsEquivalentKeyPaths : [ObjectIdentifier:Any] = [:] + +extension KeyPathLayoutEquivalent { + + /// Implements `isEquivalent(to:)` based on `isEquivalent`. + public func isEquivalent(to other: Self) -> Bool { + + let keyPaths : KeyPaths = { + let id = ObjectIdentifier(Self.self) + + if let existing = cachedIsEquivalentKeyPaths[id] { + return existing as! KeyPaths + } else { + let new = Self.isEquivalent + cachedIsEquivalentKeyPaths[id] = new + return new + } + }() + + for keyPath in keyPaths { + if keyPath.compare(self, other) == false { + return false + } + } + + return true + } +} + + +public struct KeyPathLayoutEquivalentKeyPath { + + let compare : (Value, Value) -> Bool + + fileprivate init(_ keyPath : KeyPath) { + compare = { lhs, rhs in + lhs[keyPath: keyPath] == rhs[keyPath: keyPath] + } + } + + fileprivate init(_ keyPath : KeyPath) { + compare = { lhs, rhs in + lhs[keyPath: keyPath].isEquivalent(to: rhs[keyPath: keyPath]) + } + } +} + + +@resultBuilder public struct KeyPathLayoutEquivalentBuilder { + + // https://github.com/apple/swift-evolution/blob/main/proposals/0289-result-builders.md + + public typealias Component = Value.KeyPaths + + public static func buildExpression( + _ keyPath: KeyPath + ) -> Component { + [.init(keyPath)] + } + + public static func buildExpression( + _ keyPath: KeyPath + ) -> Component { + [.init(keyPath)] + } + + @available(*, unavailable, message: "A KeyPath must conform to Equatable or LayoutEquivalent to be used with `KeyPathLayoutEquivalent`.") + public static func buildExpression( + _ keyPath: KeyPath + ) -> Component { + fatalError() + } + + public static func buildBlock(_ component: Component...) -> Component { + component.flatMap { $0 } + } +} diff --git a/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift b/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift new file mode 100644 index 000000000..8bfee9c4c --- /dev/null +++ b/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift @@ -0,0 +1,94 @@ +// +// LayoutEquivalent.swift +// ListableUI +// +// Created by Kyle Van Essen on 11/28/21. +// + +import Foundation + + +/// Used by the list to determine when the content of content has changed; in order to +/// remeasure the content and re-layout the list. +/// +/// ## Note +/// If you conform to `Equatable`, your value will receive `LayoutEquivalent` +/// conformance for free. If you need to implement `LayoutEquivalent` manually, +/// consider using `KeyPathLayoutEquivalent` as a more declarative way to denote +/// which key paths should be used in the `isEquivalent(to:)` comparison. +public protocol LayoutEquivalent { + + /// + /// Used by the list to determine when the content of content has changed; in order to + /// remeasure the content and re-layout the list. + /// + /// You should return `false` from this method when any values within your content that + /// affects visual appearance or layout (and in particular, sizing) changes. When the list + /// receives `false` back from this method, it will invalidate any cached sizing it has stored + /// for the content, and re-measure + re-layout the content. + /// + /// ## ⚠️ Important + /// `isEquivalent(to:)` is **not** an identifier check. That is what the `identifierValue` + /// on your `ItemContent` is for. It is to determine when content has meaningfully changed. + /// + /// ## 🤔 Examples & How To + /// + /// ```swift + /// struct MyItemContent : ItemContent, Equatable { + /// + /// var identifierValue : UUID + /// var title : String + /// var detail : String + /// var theme : MyTheme + /// var onTapDetail : () -> () + /// + /// func isEquivalent(to other : MyItemContent) -> Bool { + /// // 🚫 Missing checks for title and detail. + /// // If they change, they likely affect sizing, + /// // which would result in incorrect item sizing. + /// + /// self.theme == other.theme + /// } + /// + /// func isEquivalent(to other : MyItemContent) -> Bool { + /// // 🚫 Missing check for theme. + /// // If the theme changed; its likely that the device's + /// // accessibility settings changed; dark mode was enabled, + /// // etc. All of these can affect the appearance or sizing + /// // of the item. + /// + /// self.title == other.title && + /// self.detail == other.detail + /// } + /// + /// func isEquivalent(to other : MyItemContent) -> Bool { + /// // ✅ Checking all parameters which can affect appearance + layout. + /// // 💡 Not checking identifierValue or onTapDetail, since they do not affect appearance + layout. + /// + /// self.theme == other.theme && + /// self.title == other.title && + /// self.detail == other.detail + /// } + /// } + /// + /// struct MyItemContent : ItemContent, Equatable { + /// // ✅ Nothing else needed! + /// // `Equatable` conformance provides `isEquivalent(to:) for free!` + /// } + /// ``` + /// + /// ## Note + /// If your ``ItemContent`` conforms to ``Equatable``, there is a default + /// implementation of this method which simply returns `self == other`. + /// + func isEquivalent(to other : Self) -> Bool +} + + +public extension LayoutEquivalent where Self:Equatable +{ + /// If your content is `Equatable`, `isEquivalent` is based on the `Equatable` implementation. + func isEquivalent(to other : Self) -> Bool { + self == other + } +} diff --git a/ListableUI/Sources/Item/ItemContent.swift b/ListableUI/Sources/Item/ItemContent.swift index 93b8937b9..7b34c9174 100644 --- a/ListableUI/Sources/Item/ItemContent.swift +++ b/ListableUI/Sources/Item/ItemContent.swift @@ -40,7 +40,7 @@ import UIKit /// z-index 2) `SelectedBackgroundView` (Only if the item supports a `selectionStyle` and is selected or highlighted.) /// z-index 1) `BackgroundView` /// -public protocol ItemContent : AnyItemConvertible where Coordinator.ItemContentType == Self +public protocol ItemContent : LayoutEquivalent, AnyItemConvertible where Coordinator.ItemContentType == Self { // // MARK: Identification @@ -121,6 +121,7 @@ public protocol ItemContent : AnyItemConvertible where Coordinator.ItemContentTy /// text fields, etc. The identifier of the control should be stable and **independent of the value /// the control is currently representing**. Including the value the control is currently representing /// in the identifier will cause the list to repeatedly re-create the control, removing the old item and inserting the new one. + /// /// ```swift /// struct MySearchBarRow : ItemContent { /// @@ -238,65 +239,6 @@ public protocol ItemContent : AnyItemConvertible where Coordinator.ItemContentTy // MARK: Tracking Changes // - /// - /// Used by the list to determine when the content of the item has changed; in order to - /// remeasure the item and re-layout the list. - /// - /// You should return `false` from this method when any content within your item that - /// affects visual appearance or layout (and in particular, sizing) changes. When the list - /// receives `false` back from this method, it will invalidate any cached sizing it has stored - /// for the item, and re-measure + re-layout the content. - /// - /// ```swift - /// struct MyItemContent : ItemContent, Equatable { - /// - /// var identifierValue : UUID - /// var title : String - /// var detail : String - /// var theme : MyTheme - /// var onTapDetail : () -> () - /// - /// func isEquivalent(to other : MyItemContent) -> Bool { - /// // 🚫 Missing checks for title and detail. - /// // If they change, they likely affect sizing, - /// // which would result in incorrect item sizing. - /// - /// self.theme == other.theme - /// } - /// - /// func isEquivalent(to other : MyItemContent) -> Bool { - /// // 🚫 Missing check for theme. - /// // If the theme changed; its likely that the device's - /// // accessibility settings changed; dark mode was enabled, - /// // etc. All of these can affect the appearance or sizing - /// // of the item. - /// - /// self.title == other.title && - /// self.detail == other.detail - /// } - /// - /// func isEquivalent(to other : MyItemContent) -> Bool { - /// // ✅ Checking all parameters which can affect appearance + layout. - /// // Not checking identifierValue or onTapDetail, since they do not affect appearance + layout. - /// - /// self.theme == other.theme && - /// self.title == other.title && - /// self.detail == other.detail - /// } - /// } - /// - /// struct MyItemContent : ItemContent, Equatable { - /// // ✅ Nothing else needed! - /// // `Equatable` conformance provides `isEquivalent(to:) for free!` - /// } - /// ``` - /// - /// #### Note - /// If your ``ItemContent`` conforms to ``Equatable``, there is a default - /// implementation of this method which simply returns `self == other`. - /// - func isEquivalent(to other : Self) -> Bool - /// Used by the list view to determine move events during an update's diff operation. /// /// This function should return `true` if the content's sort changed based on the old value passed into the function. @@ -393,8 +335,7 @@ public protocol ItemContent : AnyItemConvertible where Coordinator.ItemContentTy /// Create and return a new background view used to render the content's background. /// - /// Note - /// ---- + /// ### Note /// Do not do configuration in this method that will be changed by your view's theme or appearance – instead /// do that work in `apply(to:)`, so the appearance will be updated if the appearance of content changes. static func createReusableBackgroundView(frame : CGRect) -> BackgroundView @@ -402,8 +343,7 @@ public protocol ItemContent : AnyItemConvertible where Coordinator.ItemContentTy /// The selected background view used to draw the background of the content when it is selected or highlighted. /// The selected background view is drawn below the content view. /// - /// Note - /// ---- + /// ### Note /// Defaults to a `UIView` with no drawn appearance or state. /// You do not need to provide this `typealias` unless you would like /// to draw a selected background view. @@ -418,8 +358,7 @@ public protocol ItemContent : AnyItemConvertible where Coordinator.ItemContentTy /// If your `BackgroundView` and `SelectedBackgroundView` are the same type, this method /// is provided automatically by calling `createReusableBackgroundView`. /// - /// Note - /// ---- + /// ### Note /// Do not do configuration in this method that will be changed by your view's theme or appearance – instead /// do that work in `apply(to:)`, so the appearance will be updated if the appearance of content changes. static func createReusableSelectedBackgroundView(frame : CGRect) -> SelectedBackgroundView @@ -577,14 +516,6 @@ public extension ItemContent { } } -public extension ItemContent where Self:Equatable -{ - /// If your `ItemContent` is `Equatable`, `isEquivalent` is based on the `Equatable` implementation. - func isEquivalent(to other : Self) -> Bool { - self == other - } -} - public extension ItemContent { @@ -622,7 +553,7 @@ public extension ItemContent { /// Provides a default implementation of `identifierValue` when self conforms to Swift's `Identifiable` protocol. public extension ItemContent where Self:Identifiable -{ +{ var identifierValue : ID { self.id } diff --git a/ListableUI/Sources/ListProperties.swift b/ListableUI/Sources/ListProperties.swift index 30a4f4965..dcdb08ef6 100644 --- a/ListableUI/Sources/ListProperties.swift +++ b/ListableUI/Sources/ListProperties.swift @@ -283,7 +283,7 @@ import UIKit /// } /// ``` public mutating func add( - @ListableBuilder
sections : () -> [Section] + @ListableArrayBuilder
sections : () -> [Section] ) { self.content.sections += sections() } diff --git a/ListableUI/Sources/ListableBuilder.swift b/ListableUI/Sources/ListableBuilder.swift index a9023f77c..3475998cc 100644 --- a/ListableUI/Sources/ListableBuilder.swift +++ b/ListableUI/Sources/ListableBuilder.swift @@ -1,5 +1,5 @@ // -// ListableBuilder.swift +// ListableArrayBuilder.swift // ListableUI // // Created by Kyle Van Essen on 6/10/21. @@ -11,7 +11,7 @@ /// You provide a result builder in an API by specifying it as a method parameter, like so: /// /// ``` -/// init(@ListableBuilder contents : () -> [SomeContent]) { +/// init(@ListableArrayBuilder contents : () -> [SomeContent]) { /// self.contents = contents() /// } /// ``` @@ -25,7 +25,7 @@ /// ### Note /// Most comments on methods come from the result builders SE proposal. /// -@resultBuilder public enum ListableBuilder { +@resultBuilder public enum ListableArrayBuilder { /// The type of individual statement expressions in the transformed function. public typealias Expression = ContentType @@ -86,3 +86,46 @@ component } } + + +/// +/// A result builder which can be used to provide a SwiftUI-like DSL for building a single item. +/// +/// You provide a result builder in an API by specifying it as a method parameter, like so: +/// +/// ``` +/// init(@ListableOptionalBuilder thing : () -> SomeContent?) { +/// self.thing = thing() +/// } +/// ``` +/// +/// ## Links & Videos +/// https://github.com/apple/swift-evolution/blob/main/proposals/0289-result-builders.md +/// https://developer.apple.com/videos/play/wwdc2021/10253/ +/// https://www.swiftbysundell.com/articles/deep-dive-into-swift-function-builders/ +/// https://www.avanderlee.com/swift/result-builders/ +/// +@resultBuilder public enum ListableOptionalBuilder { + + typealias Component = ContentType? + + public static func buildBlock() -> ContentType? { + nil + } + + public static func buildBlock(_ content: ContentType?) -> ContentType? { + content + } + + public static func buildOptional(_ component: ContentType??) -> ContentType? { + component ?? nil + } + + public static func buildEither(first component: ContentType?) -> ContentType? { + component + } + + public static func buildEither(second component: ContentType?) -> ContentType? { + component + } +} diff --git a/ListableUI/Sources/Section/Section.swift b/ListableUI/Sources/Section/Section.swift index 90967d7ae..3395ae489 100644 --- a/ListableUI/Sources/Section/Section.swift +++ b/ListableUI/Sources/Section/Section.swift @@ -85,8 +85,7 @@ public struct Section header : AnyHeaderFooterConvertible? = nil, footer : AnyHeaderFooterConvertible? = nil, reordering : SectionReordering = .init(), - items : [AnyItemConvertible] = [], - configure : Configure = { _ in } + items : [AnyItemConvertible] = [] ) { self.identifier = Identifier(identifier) @@ -98,8 +97,6 @@ public struct Section self.reordering = reordering self.items = items.map { $0.toAnyItem() } - - configure(&self) } /// Creates a new section with a trailing closure to configure the section inline. @@ -123,9 +120,9 @@ public struct Section _ identifier : IdentifierValue, layouts : SectionLayouts = .init(), reordering : SectionReordering = .init(), - @ListableBuilder items : () -> [AnyItemConvertible], - header : () -> AnyHeaderFooterConvertible? = { nil }, - footer : () -> AnyHeaderFooterConvertible? = { nil } + @ListableArrayBuilder items : () -> [AnyItemConvertible], + @AnyHeaderFooterBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, + @AnyHeaderFooterBuilder footer : () -> AnyHeaderFooterConvertible? = { nil } ) { self.identifier = Identifier(identifier) @@ -138,24 +135,6 @@ public struct Section self.footer = footer() } - /// Creates a new section with result builder-style APIs. - public init( - _ identifier : IdentifierValue, - @ListableBuilder items : () -> [AnyItemConvertible], - header : () -> AnyHeaderFooterConvertible? = { nil }, - footer : () -> AnyHeaderFooterConvertible? = { nil } - ) { - self.identifier = Identifier(identifier) - - self.layouts = .init() - self.reordering = .init() - - self.items = items().map { $0.toAnyItem() } - - self.header = header() - self.footer = footer() - } - // // MARK: Reading Items // @@ -232,7 +211,7 @@ public struct Section /// } /// ``` public mutating func add( - @ListableBuilder items : () -> [AnyItemConvertible] + @ListableArrayBuilder items : () -> [AnyItemConvertible] ) { self.items += items().map { $0.toAnyItem() } } diff --git a/ListableUI/Sources/SwipeActionsConfiguration.swift b/ListableUI/Sources/SwipeActionsConfiguration.swift index 0692e0101..d9d42e3f6 100644 --- a/ListableUI/Sources/SwipeActionsConfiguration.swift +++ b/ListableUI/Sources/SwipeActionsConfiguration.swift @@ -40,7 +40,7 @@ public struct SwipeActionsConfiguration { /// Creates a new configuration with the provided actions. public init( performsFirstActionWithFullSwipe : Bool = false, - @ListableBuilder actions : () -> [SwipeAction] + @ListableArrayBuilder actions : () -> [SwipeAction] ) { self.performsFirstActionWithFullSwipe = performsFirstActionWithFullSwipe self.actions = actions() diff --git a/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift b/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift new file mode 100644 index 000000000..85d14c90d --- /dev/null +++ b/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift @@ -0,0 +1,59 @@ +// +// KeyPathLayoutEquivalentTests.swift +// ListableUI-Unit-Tests +// +// Created by Kyle Van Essen on 5/24/23. +// + +import ListableUI +import XCTest + +class KeyPathLayoutEquivalentTests : XCTestCase { + + func test_isEquivalent() { + +struct TestingThing : KeyPathLayoutEquivalent { + + var name : String + var age : Int + var birthdate : Date + var nonCompared : Bool + + static var isEquivalent: KeyPaths { + \.name + \.age + \.birthdate + } +} + + let value1 = TestingThing( + name: "1", + age: 0, + birthdate: Date(), + nonCompared: false + ) + + let equivalentToValue1 = TestingThing( + name: "1", + age: 0, + birthdate: Date(), + nonCompared: true + ) + + let notEquivalentToValue1 = TestingThing( + name: "2", + age: 0, + birthdate: Date(), + nonCompared: false + ) + + XCTAssertTrue(value1.isEquivalent(to: equivalentToValue1)) + XCTAssertFalse(value1.isEquivalent(to: notEquivalentToValue1)) + + /// Our implementation caches the result of `isEquivalent`, + /// ensure calling the above again does not crash when retrieving values from the cache. + + XCTAssertTrue(value1.isEquivalent(to: equivalentToValue1)) + XCTAssertFalse(value1.isEquivalent(to: notEquivalentToValue1)) + } +} diff --git a/ListableUI/Tests/ListableBuilderTests.swift b/ListableUI/Tests/ListableBuilderTests.swift index 92d1cce0d..a525e0581 100644 --- a/ListableUI/Tests/ListableBuilderTests.swift +++ b/ListableUI/Tests/ListableBuilderTests.swift @@ -9,7 +9,7 @@ import ListableUI import XCTest -class ListableBuilderTests : XCTestCase { +class ListableArrayBuilderTests : XCTestCase { func test_empty() { let content : [String] = build {} @@ -158,11 +158,231 @@ class ListableBuilderTests : XCTestCase { ) } - fileprivate func build( - @ListableBuilder using builder : () -> [Content] + func test_item_default_implementation_resolution() { + + var callCount : Int = 0 + + let sections : [Section] = [ + Section("1") { + EquatableContent { callCount += 1 } + Item(EquatableContent { callCount += 1 }) + EquivalentContent { callCount += 1 } + Item(EquivalentContent { callCount += 1 }) + }, + + Section("1") { section in + section += EquatableContent { callCount += 1 } + section.add(Item(EquatableContent { callCount += 1 })) + section += EquivalentContent { callCount += 1 } + section.add(Item(EquivalentContent { callCount += 1 })) + }, + + Section("1") { section in + section.add { + EquatableContent { callCount += 1 } + Item(EquatableContent { callCount += 1 }) + EquivalentContent { callCount += 1 } + Item(EquivalentContent { callCount += 1 }) + } + } + ] + + for section in sections { + + callCount = 0 + + let equatableItem1 = section.items[0] + let equatableItem2 = section.items[1] + let equivalentItem1 = section.items[2] + let equivalentItem2 = section.items[3] + + XCTAssertTrue(equatableItem1.anyIsEquivalent(to: equatableItem1)) + XCTAssertEqual(callCount, 1) + + XCTAssertTrue(equatableItem2.anyIsEquivalent(to: equatableItem2)) + XCTAssertEqual(callCount, 2) + + XCTAssertTrue(equivalentItem1.anyIsEquivalent(to: equivalentItem1)) + XCTAssertEqual(callCount, 3) + + XCTAssertTrue(equivalentItem2.anyIsEquivalent(to: equivalentItem2)) + XCTAssertEqual(callCount, 4) + } + } + + private func build( + @ListableArrayBuilder using builder : () -> [Content] ) -> [Content] { builder() } } + +public class ListableOptionalBuilderTests : XCTestCase { + + func test_empty() { + let result : String? = build { } + + XCTAssertNil(result) + } + + func test_if() { + + /// If we use just `true` or `false`, the compiler (rightly) complains about unreachable code. + let trueValue = "true" == "true" + let falseValue = "true" == "false" + + let falseResult : String? = build { + if falseValue { + "string" + } + } + + XCTAssertNil(falseResult) + + let trueResult : String? = build { + if trueValue { + "string" + } + } + + XCTAssertEqual(trueResult, "string") + } + + func test_headerfooter_default_implementation_resolution() { + + var callCount : Int = 0 + + let equatableSection = Section("1") { + TestContent() + } header: { + EquatableHeaderFooter { callCount += 1 } + } footer: { + HeaderFooter(EquatableHeaderFooter { callCount += 1 }) + } + + let equivalentSection = Section("1") { + TestContent() + } header: { + EquivalentHeaderFooter { callCount += 1 } + } footer: { + HeaderFooter(EquivalentHeaderFooter { callCount += 1 }) + } + + let equatableItem1 = equatableSection.header!.asAnyHeaderFooter() + let equatableItem2 = equatableSection.footer!.asAnyHeaderFooter() + let equivalentItem1 = equivalentSection.header!.asAnyHeaderFooter() + let equivalentItem2 = equivalentSection.footer!.asAnyHeaderFooter() + + XCTAssertTrue(equatableItem1.anyIsEquivalent(to: equatableItem1)) + XCTAssertEqual(callCount, 1) + + XCTAssertTrue(equatableItem2.anyIsEquivalent(to: equatableItem2)) + XCTAssertEqual(callCount, 2) + + XCTAssertTrue(equivalentItem1.anyIsEquivalent(to: equivalentItem1)) + XCTAssertEqual(callCount, 3) + + XCTAssertTrue(equivalentItem2.anyIsEquivalent(to: equivalentItem2)) + XCTAssertEqual(callCount, 4) + } + + private func build( + @ListableOptionalBuilder using builder : () -> Content? + ) -> Content? + { + builder() + } +} + + +fileprivate struct TestContent : ItemContent, Equatable { + + var identifierValue: String { + "" + } + + static func createReusableContentView(frame: CGRect) -> UIView { + UIView() + } + + func apply(to views: ItemContentViews, for reason: ApplyReason, with info: ApplyItemContentInfo) {} +} + + +fileprivate struct EquatableContent : ItemContent, Equatable { + + var identifierValue: String { + "" + } + + var calledEqual : () -> () + + static func == (lhs : Self, rhs : Self) -> Bool { + lhs.calledEqual() + return true + } + + static func createReusableContentView(frame: CGRect) -> UIView { + UIView() + } + + func apply(to views: ItemContentViews, for reason: ApplyReason, with info: ApplyItemContentInfo) {} +} + + +fileprivate struct EquivalentContent : ItemContent, LayoutEquivalent { + + var identifierValue: String { + "" + } + + var calledIsEquivalent : () -> () + + func isEquivalent(to other: EquivalentContent) -> Bool { + calledIsEquivalent() + return true + } + + static func createReusableContentView(frame: CGRect) -> UIView { + UIView() + } + + func apply(to views: ItemContentViews, for reason: ApplyReason, with info: ApplyItemContentInfo) {} +} + + +fileprivate struct EquatableHeaderFooter : HeaderFooterContent, Equatable { + + var calledEqual : () -> () + + static func == (lhs : Self, rhs : Self) -> Bool { + lhs.calledEqual() + return true + } + + static func createReusableContentView(frame: CGRect) -> UIView { + UIView() + } + + func apply(to views: HeaderFooterContentViews, for reason: ApplyReason, with info: ApplyHeaderFooterContentInfo) {} +} + + +fileprivate struct EquivalentHeaderFooter : HeaderFooterContent, LayoutEquivalent { + + var calledIsEquivalent : () -> () + + func isEquivalent(to other: EquivalentHeaderFooter) -> Bool { + calledIsEquivalent() + return true + } + + static func createReusableContentView(frame: CGRect) -> UIView { + UIView() + } + + func apply(to views: HeaderFooterContentViews, for reason: ApplyReason, with info: ApplyHeaderFooterContentInfo) {} +} +