From 05cbbfebfde30c804fdb0012b71b868666899bfa Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 24 Jul 2022 17:49:07 -0700 Subject: [PATCH 01/38] Move isEquivalent to its own protocol. --- BlueprintUILists/Sources/Item.swift | 5 ++ CHANGELOG.md | 2 + .../HeaderFooter/HeaderFooterContent.swift | 17 +--- ListableUI/Sources/Item/ItemContent.swift | 82 ++----------------- 4 files changed, 14 insertions(+), 92 deletions(-) diff --git a/BlueprintUILists/Sources/Item.swift b/BlueprintUILists/Sources/Item.swift index 80aa58a37..d82355ef8 100644 --- a/BlueprintUILists/Sources/Item.swift +++ b/BlueprintUILists/Sources/Item.swift @@ -129,6 +129,7 @@ public struct ElementItemContent : Bluepr public let represented : Represented let idValueKeyPath : KeyPath + let defaults: DefaultProperties = .init() let isEquivalentProvider : (Represented, Represented) -> Bool let elementProvider : (Represented, ApplyItemContentInfo) -> Element let backgroundProvider : (Represented, ApplyItemContentInfo) -> Element? @@ -138,6 +139,10 @@ public struct ElementItemContent : Bluepr self.represented[keyPath: self.idValueKeyPath] } + public var defaultItemProperties: DefaultProperties { + defaults + } + public func isEquivalent(to other: Self) -> Bool { self.isEquivalentProvider(self.represented, other.represented) } diff --git a/CHANGELOG.md b/CHANGELOG.md index 605f8bd34..4b0aa5a05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Changed +- Definition of `isEquivalent(to:)` has been moved to `IsEquivalentContent`. + ### Misc # Past Releases diff --git a/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift b/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift index 969deb84c..a9c73c5a8 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 : IsEquivalentContent, AnyHeaderFooterConvertible { - // - // MARK: Tracking Changes - // - - func isEquivalent(to other : Self) -> Bool - // // MARK: Default Properties // @@ -191,15 +185,6 @@ public extension HeaderFooterContent { } -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 - } -} - - public extension HeaderFooterContent where Self.BackgroundView == UIView { static func createReusableBackgroundView(frame : CGRect) -> BackgroundView diff --git a/ListableUI/Sources/Item/ItemContent.swift b/ListableUI/Sources/Item/ItemContent.swift index 30a3baed7..3b5923165 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 : IsEquivalentContent, 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. @@ -384,8 +326,7 @@ public protocol ItemContent : AnyItemConvertible where Coordinator.ItemContentTy /// The background view used to draw the background of the content. /// The 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 background view. @@ -394,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 @@ -403,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. @@ -419,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 @@ -501,14 +439,6 @@ public extension ItemContent where SwipeActionsView.Style == DefaultSwipeActions } } -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 { From ca36edc06be2b28282851c3fccd06ca1965edd8d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 24 Jul 2022 17:49:18 -0700 Subject: [PATCH 02/38] Update APIs used to add elements to lists --- .../Sources/Element+HeaderFooter.swift | 89 +++++++++++++ BlueprintUILists/Sources/Element+Item.swift | 122 ++++++++++++++++++ ...Footer.swift => ElementHeaderFooter.swift} | 6 +- .../Sources/{Item.swift => ElementItem.swift} | 11 +- BlueprintUILists/Sources/List.swift | 21 ++- .../Sources/ListableBuilder+Element.swift | 99 ++++++++++++++ .../Sources/Section+Element.swift | 55 ++++++++ ...stableBuilderAndSectionOverloadTests.swift | 90 +++++++++++++ CHANGELOG.md | 15 +++ .../Sources/HeaderFooter/HeaderFooter.swift | 2 +- .../HeaderFooter/HeaderFooterContent.swift | 2 + .../IsEquivalent/IsEquivalentContent.swift | 91 +++++++++++++ ListableUI/Sources/ListableBuilder.swift | 49 +++++++ ListableUI/Sources/Section/Section.swift | 18 --- 14 files changed, 640 insertions(+), 30 deletions(-) create mode 100644 BlueprintUILists/Sources/Element+HeaderFooter.swift create mode 100644 BlueprintUILists/Sources/Element+Item.swift rename BlueprintUILists/Sources/{HeaderFooter.swift => ElementHeaderFooter.swift} (95%) rename BlueprintUILists/Sources/{Item.swift => ElementItem.swift} (96%) create mode 100644 BlueprintUILists/Sources/ListableBuilder+Element.swift create mode 100644 BlueprintUILists/Sources/Section+Element.swift create mode 100644 BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift create mode 100644 ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift new file mode 100644 index 000000000..7756b88a0 --- /dev/null +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -0,0 +1,89 @@ +// +// Element+HeaderFooter.swift +// BlueprintUILists +// +// Created by Kyle Van Essen on 7/24/22. +// + +import BlueprintUI +import ListableUI + + +// MARK: HeaderFooter / HeaderFooterContent Extensions + + +extension Element { + + /// Converts the given `Element` into a Listable `HeaderFooter`. You many also optionally + /// configure the header / footer, setting its values such as the `onTap` callbacks, etc. + /// + /// ```swift + /// MyElement(...) + /// .headerFooter { header in + /// header.onTap = { ... } + /// } + /// ``` + /// + /// ## ⚠️ Performance Considerations + /// Unless your `Element` conforms to `Equatable` or `IsEquivalentContent`, + /// it will return `false` for `isEquivalent` for each content update, which can dramatically + /// hurt performance for longer lists (eg, more than 20 items): it will be re-measured for each content update. + /// + /// It is encouraged for these longer lists, you ensure your `Element` conforms to one of these protocols. + public func headerFooter( + configure : (inout HeaderFooter>) -> () = { _ in } + ) -> HeaderFooter> { + HeaderFooter( + WrappedHeaderFooterContent( + represented: self + ), + configure: configure + ) + } + + /// Used by internal Listable methods to convert type-erased `Element` instances into `Item` instances. + func toHeaderFooterConvertible() -> AnyHeaderFooterConvertible { + /// We use `type(of:)` to ensure we get the actual type, not just `Element`. + WrappedHeaderFooterContent( + represented: self + ) + } +} + + +public struct WrappedHeaderFooterContent : BlueprintHeaderFooterContent +{ + public let represented : ElementType + + public func isEquivalent(to other: Self) -> Bool { + false + } + + public var elementRepresentation: Element { + represented + } +} + + +extension WrappedHeaderFooterContent where ElementType : Equatable { + + public func isEquivalent(to other: Self) -> Bool { + represented == other.represented + } + + public var reappliesToVisibleView: ReappliesToVisibleView { + .ifNotEquivalent + } +} + + +extension WrappedHeaderFooterContent where ElementType : IsEquivalentContent { + + public func isEquivalent(to other: Self) -> Bool { + represented.isEquivalent(to: other.represented) + } + + public var reappliesToVisibleView: ReappliesToVisibleView { + .ifNotEquivalent + } +} diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift new file mode 100644 index 000000000..cf3f863ef --- /dev/null +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -0,0 +1,122 @@ +// +// Element+Item.swift +// BlueprintUILists +// +// Created by Kyle Van Essen on 7/24/22. +// + +import BlueprintUI +import ListableUI + + +// MARK: Item / ItemContent Extensions + +extension Element { + + /// Converts the given `Element` into a Listable `Item`. You many also optionally + /// configure the item, setting its values such as the `onDisplay` callbacks, etc. + /// + /// ```swift + /// MyElement(...) + /// .item { item in + /// item.insertAndRemoveAnimations = .scaleUp + /// } + /// ``` + /// + /// ## ⚠️ Performance Considerations + /// Unless your `Element` conforms to `Equatable` or `IsEquivalentContent`, + /// it will return `false` for `isEquivalent` for each content update, which can dramatically + /// hurt performance for longer lists (eg, more than 20 items): it will be re-measured for each content update. + /// + /// It is encouraged for these longer lists, you ensure your `Element` conforms to one of these protocols. + public func item( + configure : (inout Item>) -> () = { _ in } + ) -> Item> { + Item( + WrappedElementContent( + represented: self, + identifierValue: ObjectIdentifier(Self.Type.self) + ), + configure: configure + ) + } + + /// Converts the given `Element` into a Listable `Item` with the provided ID. You can use this ID + /// to scroll to or later access the item through the regular list access APIs. + /// You many also optionally configure the item, setting its values such as the `onDisplay` callbacks, etc. + /// + /// ```swift + /// MyElement(...) + /// .item(id: "my-provided-id") { item in + /// item.insertAndRemoveAnimations = .scaleUp + /// } + /// ``` + /// + /// ## ⚠️ Performance Considerations + /// Unless your `Element` conforms to `Equatable` or `IsEquivalentContent`, + /// it will return `false` for `isEquivalent` for each content update, which can dramatically + /// hurt performance for longer lists (eg, more than 20 items): it will be re-measured for each content update. + /// + /// It is encouraged for these longer lists, you ensure your `Element` conforms to one of these protocols. + public func item( + id : ID, + configure : (inout Item>) -> () = { _ in } + ) -> Item> { + Item( + WrappedElementContent( + represented: self, + identifierValue: id + ), + configure: configure + ) + } + + /// Used by internal Listable methods to convert type-erased `Element` instances into `Item` instances. + func toAnyItemConvertible() -> AnyItemConvertible { + /// We use `type(of:)` to ensure we get the actual type, not just `Element`. + WrappedElementContent( + represented: self, + identifierValue: ObjectIdentifier(type(of: self)) + ) + } +} + + +public struct WrappedElementContent : BlueprintItemContent +{ + public let represented : ElementType + + public let identifierValue: IdentifierValue + + public func isEquivalent(to other: Self) -> Bool { + false + } + + public func element(with info: ApplyItemContentInfo) -> Element { + represented + } +} + + +extension WrappedElementContent where ElementType : Equatable { + + public func isEquivalent(to other: Self) -> Bool { + represented == other.represented + } + + public var reappliesToVisibleView: ReappliesToVisibleView { + .ifNotEquivalent + } +} + + +extension WrappedElementContent where ElementType : IsEquivalentContent { + + public func isEquivalent(to other: Self) -> Bool { + represented.isEquivalent(to: other.represented) + } + + public var reappliesToVisibleView: ReappliesToVisibleView { + .ifNotEquivalent + } +} 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..4a12e352a 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.headerFooter(...)` 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.headerFooter(...)` 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 d82355ef8..fd52ea6f3 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.item(...)` 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.item(...)` instead. +/// /// Provides a way to create an `Item` for your Blueprint elements without /// requiring the creation of a new `BlueprintItemContent` struct. /// @@ -129,7 +133,6 @@ public struct ElementItemContent : Bluepr public let represented : Represented let idValueKeyPath : KeyPath - let defaults: DefaultProperties = .init() let isEquivalentProvider : (Represented, Represented) -> Bool let elementProvider : (Represented, ApplyItemContentInfo) -> Element let backgroundProvider : (Represented, ApplyItemContentInfo) -> Element? @@ -139,10 +142,6 @@ public struct ElementItemContent : Bluepr self.represented[keyPath: self.idValueKeyPath] } - public var defaultItemProperties: DefaultProperties { - defaults - } - public func isEquivalent(to other: Self) -> Bool { self.isEquivalentProvider(self.represented, other.represented) } diff --git a/BlueprintUILists/Sources/List.swift b/BlueprintUILists/Sources/List.swift index 19cde8306..ebeefa05e 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] + @ListableBuilder
sections : () -> [Section], + @ListableOptionalBuilder containerHeader : () -> AnyHeaderFooterConvertible? = { nil }, + @ListableOptionalBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, + @ListableOptionalBuilder footer : () -> AnyHeaderFooterConvertible? = { nil }, + @ListableOptionalBuilder overscrollFooter : () -> AnyHeaderFooterConvertible? = { nil } ) { self.measurement = measurement - self.properties = .default(with: configure) + var properties = ListProperties.default { + $0.sections = sections() + + $0.content.containerHeader = containerHeader() + $0.content.header = header() + $0.content.footer = footer() + $0.content.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..84d9c5669 --- /dev/null +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -0,0 +1,99 @@ +// +// ListableBuilder+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 +/// } +/// ``` +/// +/// ## Note +/// Takes advantage of `@_disfavoredOverload` to avoid ambiguous method resolution with the default implementations. +/// See more here: https://github.com/apple/swift/blob/main/docs/ReferenceGuides/UnderscoredAttributes.md#_disfavoredoverload +/// +public extension ListableBuilder where ContentType == AnyItemConvertible { + + /// Required by every result builder to build combined results from statement blocks. + @_disfavoredOverload static func buildBlock(_ components: [Element]...) -> Component { + components.reduce(into: []) { $0 += $1.map { $0.toAnyItemConvertible() } } + } + + /// If declared, provides contextual type information for statement expressions to translate them into partial results. + @_disfavoredOverload static func buildExpression(_ expression: Element) -> Component { + [expression.toAnyItemConvertible()] + } + + /// If declared, provides contextual type information for statement expressions to translate them into partial results. + @_disfavoredOverload static func buildExpression(_ expression: [Element]) -> Component { + expression.map { $0.toAnyItemConvertible() } + } + + /// Enables support for `if` statements that do not have an `else`. + @_disfavoredOverload static func buildOptional(_ component: [Element]?) -> Component { + component?.map { $0.toAnyItemConvertible() } ?? [] + } + + /// With buildEither(second:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. + @_disfavoredOverload static func buildEither(first component: [Element]) -> Component { + component.map { $0.toAnyItemConvertible() } + } + + /// With buildEither(first:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. + @_disfavoredOverload static func buildEither(second component: [Element]) -> Component { + component.map { $0.toAnyItemConvertible() } + } + + /// Enables support for 'for..in' loops by combining the results of all iterations into a single result. + @_disfavoredOverload static func buildArray(_ components: [[Element]]) -> Component { + components.flatMap { $0.map { $0.toAnyItemConvertible() } } + } + + /// If declared, this will be called on the partial result of an `if #available` block to allow the result builder to erase type information. + @_disfavoredOverload static func buildLimitedAvailability(_ component: [Element]) -> Component { + component.map { $0.toAnyItemConvertible() } + } + + /// If declared, this will be called on the partial result from the outermost block statement to produce the final returned result. + @_disfavoredOverload static func buildFinalResult(_ component: [Element]) -> FinalResult { + component.map { $0.toAnyItemConvertible() } + } +} + + +public extension ListableOptionalBuilder where ContentType == AnyHeaderFooterConvertible { + + + /// Enables support for `if` statements that do not have an `else`. + @_disfavoredOverload static func buildOptional(_ component: Element?) -> Component { + component?.toHeaderFooterConvertible() + } + + /// With buildEither(second:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. + @_disfavoredOverload static func buildEither(first component: Element) -> Component { + component.toHeaderFooterConvertible() + } + + /// With buildEither(first:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. + @_disfavoredOverload static func buildEither(second component: Element) -> Component { + component.toHeaderFooterConvertible() + } + + /// If declared, this will be called on the partial result of an `if #available` block to allow the result builder to erase type information. + @_disfavoredOverload static func buildLimitedAvailability(_ component: Element) -> Component { + component.toHeaderFooterConvertible() + } +} + diff --git a/BlueprintUILists/Sources/Section+Element.swift b/BlueprintUILists/Sources/Section+Element.swift new file mode 100644 index 000000000..9edabf069 --- /dev/null +++ b/BlueprintUILists/Sources/Section+Element.swift @@ -0,0 +1,55 @@ +// +// 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(_ item : Element) + { + self.items.append(item.toAnyItemConvertible().toAnyItem()) + } + + /// ```swift + /// Section("id") { section in + /// section += Element1() + /// section += Element2() + /// } + /// ``` + public static func += (lhs : inout Section, rhs : Element) + { + lhs.add(rhs) + } + + /// Adds `Element` support when building a `Section`: + /// + /// ```swift + /// Section("3") { section in + /// section.add { + /// TestContent1() // An ItemContent + /// + /// Element1() // A Element + /// Element2() // A Element + /// } + /// } + /// ``` + public mutating func add( + @ListableBuilder items : () -> [Element] + ) { + self.items += items().map { $0.toAnyItemConvertible().toAnyItem() } + } +} diff --git a/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift b/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift new file mode 100644 index 000000000..d25e4ea3c --- /dev/null +++ b/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift @@ -0,0 +1,90 @@ +// +// ListableBuilderAndSectionOverloadTests.swift +// BlueprintUILists +// +// Created by Kyle Van Essen on 7/24/22. +// + +import BlueprintUILists +import XCTest + + +class ListableBuilderAndSectionOverloadTests : XCTestCase { + + func test_build() { + + let list = List { + Section("1") { + TestContent1() + TestContent1() + TestContent2() + + Element1() + Element2() + } + + 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) + } +} + + +fileprivate struct Element1 : ProxyElement { + + var elementRepresentation: Element { + Empty() + } +} + + +fileprivate struct Element2 : ProxyElement { + + var elementRepresentation: Element { + Empty() + } +} + + +fileprivate struct TestContent1 : BlueprintItemContent, Equatable { + + var identifierValue: String { + "1" + } + + func element(with info: ApplyItemContentInfo) -> Element { + Empty() + } +} + + +fileprivate struct TestContent2 : BlueprintItemContent, Equatable { + + var identifierValue: String { + "1" + } + + func element(with info: ApplyItemContentInfo) -> Element { + Empty() + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0aa5a05..377fdad92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ ### Added +- Adding Blueprint Elements to your list content has become easier: Just add them directly! You no longer need to use the `ElementItem` wrapper, unless you need to provide an isEquivalent implementation, a background view, or a selected background view, in which case, you are encouraged to use `BlueprintItemContent` or `BlueprintHeaderFooterContent` directly. 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() + .item(id: "my-specified-id") { item in + item.insertAndRemoveAnimations = .scaleUp + } + } + ``` + ### Removed ### Changed diff --git a/ListableUI/Sources/HeaderFooter/HeaderFooter.swift b/ListableUI/Sources/HeaderFooter/HeaderFooter.swift index 110cbe3b0..d9cb7eef5 100644 --- a/ListableUI/Sources/HeaderFooter/HeaderFooter.swift +++ b/ListableUI/Sources/HeaderFooter/HeaderFooter.swift @@ -12,7 +12,7 @@ public typealias Header = HeaderFooter public typealias Footer = HeaderFooter -public struct HeaderFooter : AnyHeaderFooter +public struct HeaderFooter : AnyHeaderFooter, AnyHeaderFooterConvertible { public var content : Content diff --git a/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift b/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift index a9c73c5a8..61e256aae 100644 --- a/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift +++ b/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift @@ -182,6 +182,8 @@ public extension HeaderFooterContent { func asAnyHeaderFooter() -> AnyHeaderFooter { HeaderFooter(self) } + + } diff --git a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift new file mode 100644 index 000000000..3617abb3f --- /dev/null +++ b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift @@ -0,0 +1,91 @@ +// +// IsEquivalentContent.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. +/// +/// Please see ``IsEquivalentContent/isEquivalent(to:)-15tcq`` for a full discussion of +/// correct (and incorrect) implementation and usages. +public protocol IsEquivalentContent { + + /// + /// 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 IsEquivalentContent 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/ListableBuilder.swift b/ListableUI/Sources/ListableBuilder.swift index a9023f77c..0bf83e9e5 100644 --- a/ListableUI/Sources/ListableBuilder.swift +++ b/ListableUI/Sources/ListableBuilder.swift @@ -86,3 +86,52 @@ 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(@ListableBuilder 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 { + + /// The final value from the builder. + public typealias Component = ContentType? + + /// If an empty closure is provided, returns an empty array. + public static func buildBlock() -> Component { + nil + } + + /// Enables support for `if` statements that do not have an `else`. + public static func buildOptional(_ component: Component?) -> Component { + component ?? nil + } + + /// With buildEither(second:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. + public static func buildEither(first component: Component) -> Component { + component + } + + /// With buildEither(first:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. + public static func buildEither(second component: Component) -> Component { + component + } + + /// If declared, this will be called on the partial result of an `if #available` block to allow the result builder to erase type information. + public static func buildLimitedAvailability(_ component: Component) -> Component { + component + } +} diff --git a/ListableUI/Sources/Section/Section.swift b/ListableUI/Sources/Section/Section.swift index 90967d7ae..04fbd7f68 100644 --- a/ListableUI/Sources/Section/Section.swift +++ b/ListableUI/Sources/Section/Section.swift @@ -138,24 +138,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 // From 0937a2e6eb63f0987a276ad07e4c20d05b88a4f5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 25 Jul 2022 12:35:26 -0700 Subject: [PATCH 03/38] Update single builder --- .../Sources/ListableBuilder+Element.swift | 23 +++------------- ...stableBuilderAndSectionOverloadTests.swift | 4 +++ ListableUI/Sources/ListableBuilder.swift | 26 +++---------------- ListableUI/Sources/Section/Section.swift | 9 +++---- 4 files changed, 13 insertions(+), 49 deletions(-) diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index 84d9c5669..eca23949a 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -74,26 +74,9 @@ public extension ListableBuilder where ContentType == AnyItemConvertible { public extension ListableOptionalBuilder where ContentType == AnyHeaderFooterConvertible { - - - /// Enables support for `if` statements that do not have an `else`. - @_disfavoredOverload static func buildOptional(_ component: Element?) -> Component { - component?.toHeaderFooterConvertible() - } - - /// With buildEither(second:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. - @_disfavoredOverload static func buildEither(first component: Element) -> Component { - component.toHeaderFooterConvertible() - } - - /// With buildEither(first:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. - @_disfavoredOverload static func buildEither(second component: Element) -> Component { - component.toHeaderFooterConvertible() - } - - /// If declared, this will be called on the partial result of an `if #available` block to allow the result builder to erase type information. - @_disfavoredOverload static func buildLimitedAvailability(_ component: Element) -> Component { - component.toHeaderFooterConvertible() + + static func buildBlock(_ content: Element) -> ContentType { + return content.toHeaderFooterConvertible() } } diff --git a/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift b/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift index d25e4ea3c..8cab8c687 100644 --- a/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift +++ b/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift @@ -21,6 +21,10 @@ class ListableBuilderAndSectionOverloadTests : XCTestCase { Element1() Element2() + } header: { + Element1() + } footer: { + Element2().headerFooter() } Section("2") { section in diff --git a/ListableUI/Sources/ListableBuilder.swift b/ListableUI/Sources/ListableBuilder.swift index 0bf83e9e5..f4ffc79cc 100644 --- a/ListableUI/Sources/ListableBuilder.swift +++ b/ListableUI/Sources/ListableBuilder.swift @@ -107,31 +107,11 @@ /// @resultBuilder public enum ListableOptionalBuilder { - /// The final value from the builder. - public typealias Component = ContentType? - - /// If an empty closure is provided, returns an empty array. - public static func buildBlock() -> Component { + public static func buildBlock() -> ContentType? { nil } - /// Enables support for `if` statements that do not have an `else`. - public static func buildOptional(_ component: Component?) -> Component { - component ?? nil - } - - /// With buildEither(second:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. - public static func buildEither(first component: Component) -> Component { - component - } - - /// With buildEither(first:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. - public static func buildEither(second component: Component) -> Component { - component - } - - /// If declared, this will be called on the partial result of an `if #available` block to allow the result builder to erase type information. - public static func buildLimitedAvailability(_ component: Component) -> Component { - component + public static func buildBlock(_ content: ContentType?) -> ContentType? { + return content } } diff --git a/ListableUI/Sources/Section/Section.swift b/ListableUI/Sources/Section/Section.swift index 04fbd7f68..1b0518a1a 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. @@ -124,8 +121,8 @@ public struct Section layouts : SectionLayouts = .init(), reordering : SectionReordering = .init(), @ListableBuilder items : () -> [AnyItemConvertible], - header : () -> AnyHeaderFooterConvertible? = { nil }, - footer : () -> AnyHeaderFooterConvertible? = { nil } + @ListableOptionalBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, + @ListableOptionalBuilder footer : () -> AnyHeaderFooterConvertible? = { nil } ) { self.identifier = Identifier(identifier) From fa730f97462ae4161f25d133450fb41e1410c5b1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 25 Jul 2022 16:39:28 -0700 Subject: [PATCH 04/38] Remove unneeded overloads --- .../Sources/ListableBuilder+Element.swift | 49 +++---------------- .../Sources/Section+Element.swift | 24 ++------- ...stableBuilderAndSectionOverloadTests.swift | 26 ++++++++++ 3 files changed, 35 insertions(+), 64 deletions(-) diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index eca23949a..c852c9d11 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -26,57 +26,20 @@ import ListableUI /// public extension ListableBuilder where ContentType == AnyItemConvertible { - /// Required by every result builder to build combined results from statement blocks. - @_disfavoredOverload static func buildBlock(_ components: [Element]...) -> Component { - components.reduce(into: []) { $0 += $1.map { $0.toAnyItemConvertible() } } + @_disfavoredOverload static func buildBlock(_ content: ElementType) -> ContentType { + return content.item() } /// If declared, provides contextual type information for statement expressions to translate them into partial results. - @_disfavoredOverload static func buildExpression(_ expression: Element) -> Component { - [expression.toAnyItemConvertible()] - } - - /// If declared, provides contextual type information for statement expressions to translate them into partial results. - @_disfavoredOverload static func buildExpression(_ expression: [Element]) -> Component { - expression.map { $0.toAnyItemConvertible() } - } - - /// Enables support for `if` statements that do not have an `else`. - @_disfavoredOverload static func buildOptional(_ component: [Element]?) -> Component { - component?.map { $0.toAnyItemConvertible() } ?? [] - } - - /// With buildEither(second:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. - @_disfavoredOverload static func buildEither(first component: [Element]) -> Component { - component.map { $0.toAnyItemConvertible() } - } - - /// With buildEither(first:), enables support for 'if-else' and 'switch' statements by folding conditional results into a single result. - @_disfavoredOverload static func buildEither(second component: [Element]) -> Component { - component.map { $0.toAnyItemConvertible() } - } - - /// Enables support for 'for..in' loops by combining the results of all iterations into a single result. - @_disfavoredOverload static func buildArray(_ components: [[Element]]) -> Component { - components.flatMap { $0.map { $0.toAnyItemConvertible() } } - } - - /// If declared, this will be called on the partial result of an `if #available` block to allow the result builder to erase type information. - @_disfavoredOverload static func buildLimitedAvailability(_ component: [Element]) -> Component { - component.map { $0.toAnyItemConvertible() } - } - - /// If declared, this will be called on the partial result from the outermost block statement to produce the final returned result. - @_disfavoredOverload static func buildFinalResult(_ component: [Element]) -> FinalResult { - component.map { $0.toAnyItemConvertible() } + @_disfavoredOverload static func buildExpression(_ expression: ElementType) -> Component { + [expression.item()] } } public extension ListableOptionalBuilder where ContentType == AnyHeaderFooterConvertible { - static func buildBlock(_ content: Element) -> ContentType { - return content.toHeaderFooterConvertible() + static func buildBlock(_ content: ElementType) -> ContentType { + return content.headerFooter() } } - diff --git a/BlueprintUILists/Sources/Section+Element.swift b/BlueprintUILists/Sources/Section+Element.swift index 9edabf069..71dc745e1 100644 --- a/BlueprintUILists/Sources/Section+Element.swift +++ b/BlueprintUILists/Sources/Section+Element.swift @@ -19,9 +19,9 @@ extension Section { /// section.add(Element2()) /// } /// ``` - public mutating func add(_ item : Element) + public mutating func add(_ element : ElementType) { - self.items.append(item.toAnyItemConvertible().toAnyItem()) + self.items.append(element.item()) } /// ```swift @@ -30,26 +30,8 @@ extension Section { /// section += Element2() /// } /// ``` - public static func += (lhs : inout Section, rhs : Element) + public static func += (lhs : inout Section, rhs : ElementType) { lhs.add(rhs) } - - /// Adds `Element` support when building a `Section`: - /// - /// ```swift - /// Section("3") { section in - /// section.add { - /// TestContent1() // An ItemContent - /// - /// Element1() // A Element - /// Element2() // A Element - /// } - /// } - /// ``` - public mutating func add( - @ListableBuilder items : () -> [Element] - ) { - self.items += items().map { $0.toAnyItemConvertible().toAnyItem() } - } } diff --git a/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift b/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift index 8cab8c687..584a8550a 100644 --- a/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift +++ b/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift @@ -13,6 +13,32 @@ class ListableBuilderAndSectionOverloadTests : XCTestCase { func test_build() { + // 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() + } + } + let list = List { Section("1") { TestContent1() From f90444dd29dcd688466ed73ac9694c8ed641bc15 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 25 Jul 2022 16:40:10 -0700 Subject: [PATCH 05/38] Remove no longer needed type erased conversion methods --- BlueprintUILists/Sources/Element+HeaderFooter.swift | 8 -------- BlueprintUILists/Sources/Element+Item.swift | 9 --------- 2 files changed, 17 deletions(-) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 7756b88a0..99e5d574c 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -40,14 +40,6 @@ extension Element { configure: configure ) } - - /// Used by internal Listable methods to convert type-erased `Element` instances into `Item` instances. - func toHeaderFooterConvertible() -> AnyHeaderFooterConvertible { - /// We use `type(of:)` to ensure we get the actual type, not just `Element`. - WrappedHeaderFooterContent( - represented: self - ) - } } diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index cf3f863ef..d48c395e4 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -70,15 +70,6 @@ extension Element { configure: configure ) } - - /// Used by internal Listable methods to convert type-erased `Element` instances into `Item` instances. - func toAnyItemConvertible() -> AnyItemConvertible { - /// We use `type(of:)` to ensure we get the actual type, not just `Element`. - WrappedElementContent( - represented: self, - identifierValue: ObjectIdentifier(type(of: self)) - ) - } } From c8f0acc9c872da155d72a2c71188463c44f5e726 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 25 Jul 2022 18:27:14 -0700 Subject: [PATCH 06/38] Ensure we properly respect Equatable or IsEquivalentContent. --- .../Sources/Element+HeaderFooter.swift | 30 ++++- BlueprintUILists/Sources/Element+Item.swift | 121 ++++++++++-------- .../Sources/ListableBuilder+Element.swift | 23 +++- ...Tests.swift => ListableBuilderTests.swift} | 69 +++++++++- 4 files changed, 179 insertions(+), 64 deletions(-) rename BlueprintUILists/Tests/{ListableBuilderAndSectionOverloadTests.swift => ListableBuilderTests.swift} (57%) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 99e5d574c..15b4ab8d1 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -34,9 +34,33 @@ extension Element { configure : (inout HeaderFooter>) -> () = { _ in } ) -> HeaderFooter> { HeaderFooter( - WrappedHeaderFooterContent( - represented: self - ), + WrappedHeaderFooterContent(represented: self), + configure: configure + ) + } +} + + +extension Element where Self:Equatable { + + public func headerFooter( + configure : (inout HeaderFooter>) -> () = { _ in } + ) -> HeaderFooter> { + HeaderFooter( + WrappedHeaderFooterContent(represented: self), + configure: configure + ) + } +} + + +extension Element where Self:IsEquivalentContent { + + public func headerFooter( + configure : (inout HeaderFooter>) -> () = { _ in } + ) -> HeaderFooter> { + HeaderFooter( + WrappedHeaderFooterContent(represented: self), configure: configure ) } diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index d48c395e4..be83d312c 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -12,13 +12,14 @@ import ListableUI // MARK: Item / ItemContent Extensions extension Element { - - /// Converts the given `Element` into a Listable `Item`. You many also optionally - /// configure the item, setting its values such as the `onDisplay` callbacks, etc. + + /// Converts the given `Element` into a Listable `Item` with the provided ID. You can use this ID + /// to scroll to or later access the item through the regular list access APIs. + /// You many also optionally configure the item, setting its values such as the `onDisplay` callbacks, etc. /// /// ```swift /// MyElement(...) - /// .item { item in + /// .item(id: "my-provided-id") { item in /// item.insertAndRemoveAnimations = .scaleUp /// } /// ``` @@ -30,42 +31,29 @@ extension Element { /// /// It is encouraged for these longer lists, you ensure your `Element` conforms to one of these protocols. public func item( - configure : (inout Item>) -> () = { _ in } - ) -> Item> { + id : AnyHashable = ObjectIdentifier(Self.Type.self), + configure : (inout Item>) -> () = { _ in } + ) -> Item> { Item( WrappedElementContent( - represented: self, - identifierValue: ObjectIdentifier(Self.Type.self) + identifierValue: id, + represented: self ), configure: configure ) } +} + +extension Element where Self:Equatable { - /// Converts the given `Element` into a Listable `Item` with the provided ID. You can use this ID - /// to scroll to or later access the item through the regular list access APIs. - /// You many also optionally configure the item, setting its values such as the `onDisplay` callbacks, etc. - /// - /// ```swift - /// MyElement(...) - /// .item(id: "my-provided-id") { item in - /// item.insertAndRemoveAnimations = .scaleUp - /// } - /// ``` - /// - /// ## ⚠️ Performance Considerations - /// Unless your `Element` conforms to `Equatable` or `IsEquivalentContent`, - /// it will return `false` for `isEquivalent` for each content update, which can dramatically - /// hurt performance for longer lists (eg, more than 20 items): it will be re-measured for each content update. - /// - /// It is encouraged for these longer lists, you ensure your `Element` conforms to one of these protocols. - public func item( - id : ID, - configure : (inout Item>) -> () = { _ in } - ) -> Item> { + public func item( + id : AnyHashable = ObjectIdentifier(Self.Type.self), + configure : (inout Item>) -> () = { _ in } + ) -> Item> { Item( WrappedElementContent( - represented: self, - identifierValue: id + identifierValue: id, + represented: self ), configure: configure ) @@ -73,38 +61,67 @@ extension Element { } -public struct WrappedElementContent : BlueprintItemContent -{ - public let represented : ElementType - - public let identifierValue: IdentifierValue +extension Element where Self:IsEquivalentContent { - public func isEquivalent(to other: Self) -> Bool { - false - } - - public func element(with info: ApplyItemContentInfo) -> Element { - represented + public func item( + id : AnyHashable = ObjectIdentifier(Self.Type.self), + configure : (inout Item>) -> () = { _ in } + ) -> Item> { + Item( + WrappedElementContent( + identifierValue: id, + represented: self + ), + configure: configure + ) } } -extension WrappedElementContent where ElementType : Equatable { +public struct WrappedElementContent : BlueprintItemContent +{ + public let identifierValue: AnyHashable - public func isEquivalent(to other: Self) -> Bool { - represented == other.represented + public let represented : ElementType + + private let isEquivalent : (Self, Self) -> Bool + + init( + identifierValue: AnyHashable, + represented: ElementType + ) { + self.represented = represented + self.identifierValue = identifierValue + + self.isEquivalent = { _, _ in false } } - public var reappliesToVisibleView: ReappliesToVisibleView { - .ifNotEquivalent + init( + identifierValue: AnyHashable, + represented: ElementType + ) where ElementType:Equatable { + self.represented = represented + self.identifierValue = identifierValue + + self.isEquivalent = { $0.represented == $1.represented } + } + + init( + identifierValue: AnyHashable, + represented: ElementType + ) where ElementType:IsEquivalentContent { + self.represented = represented + self.identifierValue = identifierValue + + self.isEquivalent = { $0.represented.isEquivalent(to: $1.represented) } } -} - - -extension WrappedElementContent where ElementType : IsEquivalentContent { public func isEquivalent(to other: Self) -> Bool { - represented.isEquivalent(to: other.represented) + self.isEquivalent(self, other) + } + + public func element(with info: ApplyItemContentInfo) -> Element { + represented } public var reappliesToVisibleView: ReappliesToVisibleView { diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index c852c9d11..9ef58170f 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -25,21 +25,32 @@ import ListableUI /// See more here: https://github.com/apple/swift/blob/main/docs/ReferenceGuides/UnderscoredAttributes.md#_disfavoredoverload /// public extension ListableBuilder where ContentType == AnyItemConvertible { + + static func buildExpression(_ expression: ElementType) -> Component { + [expression.item()] + } - @_disfavoredOverload static func buildBlock(_ content: ElementType) -> ContentType { - return content.item() + static func buildExpression(_ expression: ElementType) -> Component where ElementType:Equatable { + [expression.item()] } - - /// If declared, provides contextual type information for statement expressions to translate them into partial results. - @_disfavoredOverload static func buildExpression(_ expression: ElementType) -> Component { + + static func buildExpression(_ expression: ElementType) -> Component where ElementType:IsEquivalentContent { [expression.item()] } } public extension ListableOptionalBuilder where ContentType == AnyHeaderFooterConvertible { - + static func buildBlock(_ content: ElementType) -> ContentType { return content.headerFooter() } + + static func buildBlock(_ content: ElementType) -> ContentType where ElementType:Equatable { + return content.headerFooter() + } + + static func buildBlock(_ content: ElementType) -> ContentType where ElementType:IsEquivalentContent { + return content.headerFooter() + } } diff --git a/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift b/BlueprintUILists/Tests/ListableBuilderTests.swift similarity index 57% rename from BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift rename to BlueprintUILists/Tests/ListableBuilderTests.swift index 584a8550a..aa19e5f66 100644 --- a/BlueprintUILists/Tests/ListableBuilderAndSectionOverloadTests.swift +++ b/BlueprintUILists/Tests/ListableBuilderTests.swift @@ -1,5 +1,5 @@ // -// ListableBuilderAndSectionOverloadTests.swift +// ListableBuilderTests.swift // BlueprintUILists // // Created by Kyle Van Essen on 7/24/22. @@ -9,9 +9,9 @@ import BlueprintUILists import XCTest -class ListableBuilderAndSectionOverloadTests : XCTestCase { +class ListableBuilderTests : XCTestCase { - func test_build() { + func test_builders() { // Make sure the various result builder methods // are present such that various control flow statements still compile. @@ -39,6 +39,8 @@ class ListableBuilderAndSectionOverloadTests : XCTestCase { } } + // Make sure building happens how we would expect. + let list = List { Section("1") { TestContent1() @@ -77,6 +79,37 @@ class ListableBuilderAndSectionOverloadTests : XCTestCase { XCTAssertEqual(list.properties.content.sections[1].count, 4) XCTAssertEqual(list.properties.content.sections[2].count, 3) } + + // TODO: Test header/footers too + + func test_default_implementation_resolution() { + + var callCount : Int = 0 + + let section = Section("1") { + EquatableElement { callCount += 1 } + EquatableElement { callCount += 1 }.item() + EquivalentElement { callCount += 1 } + EquivalentElement { callCount += 1 }.item() + } + + 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) + } } @@ -96,6 +129,36 @@ fileprivate struct Element2 : ProxyElement { } +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, IsEquivalentContent { + + var calledIsEquivalent : () -> () + + var elementRepresentation: Element { + Empty() + } + + func isEquivalent(to other: EquivalentElement) -> Bool { + calledIsEquivalent() + return true + } +} + + fileprivate struct TestContent1 : BlueprintItemContent, Equatable { var identifierValue: String { From bcc10744d68a4c34894c147993e8730d21483f9f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 25 Jul 2022 18:48:08 -0700 Subject: [PATCH 07/38] Also fix header/footer Equatable and IsEquivalent resolution. --- .../Sources/Element+HeaderFooter.swift | 41 ++++++++--------- BlueprintUILists/Sources/Element+Item.swift | 10 +++-- .../Tests/ListableBuilderTests.swift | 45 +++++++++++++++++-- 3 files changed, 69 insertions(+), 27 deletions(-) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 15b4ab8d1..19b440b02 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -71,35 +71,36 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF { public let represented : ElementType - public func isEquivalent(to other: Self) -> Bool { - false - } + private let isEquivalent : (Self, Self) -> Bool - public var elementRepresentation: Element { - represented + init(represented : ElementType) { + self.represented = represented + + self.isEquivalent = { _, _ in false } } -} - - -extension WrappedHeaderFooterContent where ElementType : Equatable { - public func isEquivalent(to other: Self) -> Bool { - represented == other.represented + init(represented : ElementType) where ElementType:Equatable { + self.represented = represented + + self.isEquivalent = { + $0.represented == $1.represented + } } - public var reappliesToVisibleView: ReappliesToVisibleView { - .ifNotEquivalent + init(represented : ElementType) where ElementType:IsEquivalentContent { + self.represented = represented + + self.isEquivalent = { + $0.represented.isEquivalent(to: $1.represented) + } } -} - - -extension WrappedHeaderFooterContent where ElementType : IsEquivalentContent { public func isEquivalent(to other: Self) -> Bool { - represented.isEquivalent(to: other.represented) + isEquivalent(self, other) } - public var reappliesToVisibleView: ReappliesToVisibleView { - .ifNotEquivalent + public var elementRepresentation: Element { + represented } } + diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index be83d312c..924340614 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -103,7 +103,9 @@ public struct WrappedElementContent : BlueprintItemContent self.represented = represented self.identifierValue = identifierValue - self.isEquivalent = { $0.represented == $1.represented } + self.isEquivalent = { + $0.represented == $1.represented + } } init( @@ -113,11 +115,13 @@ public struct WrappedElementContent : BlueprintItemContent self.represented = represented self.identifierValue = identifierValue - self.isEquivalent = { $0.represented.isEquivalent(to: $1.represented) } + self.isEquivalent = { + $0.represented.isEquivalent(to: $1.represented) + } } public func isEquivalent(to other: Self) -> Bool { - self.isEquivalent(self, other) + isEquivalent(self, other) } public func element(with info: ApplyItemContentInfo) -> Element { diff --git a/BlueprintUILists/Tests/ListableBuilderTests.swift b/BlueprintUILists/Tests/ListableBuilderTests.swift index aa19e5f66..3496ea5e6 100644 --- a/BlueprintUILists/Tests/ListableBuilderTests.swift +++ b/BlueprintUILists/Tests/ListableBuilderTests.swift @@ -79,10 +79,8 @@ class ListableBuilderTests : XCTestCase { XCTAssertEqual(list.properties.content.sections[1].count, 4) XCTAssertEqual(list.properties.content.sections[2].count, 3) } - - // TODO: Test header/footers too - - func test_default_implementation_resolution() { + + func test_item_default_implementation_resolution() { var callCount : Int = 0 @@ -110,6 +108,45 @@ class ListableBuilderTests : XCTestCase { 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 }.headerFooter() + } + + let equivalentSection = Section("1") { + Element1() + } header: { + EquivalentElement { callCount += 1 } + } footer: { + EquivalentElement { callCount += 1 }.headerFooter() + } + + 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) + + } } From 732e6525a485557560b1491eda47eff3f339e776 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 26 Jul 2022 10:18:23 -0700 Subject: [PATCH 08/38] Add ListElementNonConvertible --- .../Sources/ListableBuilder+Element.swift | 49 ++++++++++++++----- .../Tests/ListableBuilderTests.swift | 13 ++++- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index 9ef58170f..fa7b89a98 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -26,31 +26,56 @@ import ListableUI /// public extension ListableBuilder where ContentType == AnyItemConvertible { - static func buildExpression(_ expression: ElementType) -> Component { - [expression.item()] + static func buildExpression(_ element: ElementType) -> Component { + [element.item()] } - static func buildExpression(_ expression: ElementType) -> Component where ElementType:Equatable { - [expression.item()] + static func buildExpression(_ element: ElementType) -> Component where ElementType:Equatable { + [element.item()] } - static func buildExpression(_ expression: ElementType) -> Component where ElementType:IsEquivalentContent { - [expression.item()] + static func buildExpression(_ element: ElementType) -> Component where ElementType:IsEquivalentContent { + [element.item()] + } + + static func buildExpression(_ element: ElementType) -> Component where ElementType:ListElementNonConvertible { + element.listElementNonConvertibleFatal() } } public extension ListableOptionalBuilder where ContentType == AnyHeaderFooterConvertible { - static func buildBlock(_ content: ElementType) -> ContentType { - return content.headerFooter() + static func buildBlock(_ element: ElementType) -> ContentType { + return element.headerFooter() + } + + static func buildBlock(_ element: ElementType) -> ContentType where ElementType:Equatable { + return element.headerFooter() } - static func buildBlock(_ content: ElementType) -> ContentType where ElementType:Equatable { - return content.headerFooter() + static func buildBlock(_ element: ElementType) -> ContentType where ElementType:IsEquivalentContent { + return element.headerFooter() } - static func buildBlock(_ content: ElementType) -> ContentType where ElementType:IsEquivalentContent { - return content.headerFooter() + 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/Tests/ListableBuilderTests.swift b/BlueprintUILists/Tests/ListableBuilderTests.swift index 3496ea5e6..7374ea32d 100644 --- a/BlueprintUILists/Tests/ListableBuilderTests.swift +++ b/BlueprintUILists/Tests/ListableBuilderTests.swift @@ -145,7 +145,18 @@ class ListableBuilderTests : XCTestCase { XCTAssertTrue(equivalentItem2.anyIsEquivalent(to: equivalentItem2)) XCTAssertEqual(callCount, 4) - + } +} + + +fileprivate struct NonConvertibleElement : ProxyElement, ListElementNonConvertible { + + var elementRepresentation: Element { + Empty() + } + + func listElementNonConvertibleFatal() -> Never { + fatalError() } } From 89c4d7dd0de548de41e731775c9d337e0f6e3d86 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 26 Jul 2022 13:22:09 -0700 Subject: [PATCH 09/38] Update section additions --- .../Sources/Section+Element.swift | 24 ++++++- .../Tests/ListableBuilderTests.swift | 67 +++++++++++++------ 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/BlueprintUILists/Sources/Section+Element.swift b/BlueprintUILists/Sources/Section+Element.swift index 71dc745e1..e371bdbfd 100644 --- a/BlueprintUILists/Sources/Section+Element.swift +++ b/BlueprintUILists/Sources/Section+Element.swift @@ -11,7 +11,7 @@ import ListableUI extension Section { - /// Adds `Element` support when building a `Section`: + /// Adds `Element` support when building a `Section`. /// /// ```swift /// Section("id") { section in @@ -24,6 +24,18 @@ extension Section { self.items.append(element.item()) } + public mutating func add(_ element : ElementType) where ElementType:Equatable + { + self.items.append(element.item()) + } + + public mutating func add(_ element : ElementType) where ElementType:IsEquivalentContent + { + self.items.append(element.item()) + } + + /// Adds `Element` support when building a `Section`. + /// /// ```swift /// Section("id") { section in /// section += Element1() @@ -34,4 +46,14 @@ extension Section { { lhs.add(rhs) } + + public static func += (lhs : inout Section, rhs : ElementType) where ElementType:Equatable + { + lhs.add(rhs) + } + + public static func += (lhs : inout Section, rhs : ElementType) where ElementType:IsEquivalentContent + { + lhs.add(rhs) + } } diff --git a/BlueprintUILists/Tests/ListableBuilderTests.swift b/BlueprintUILists/Tests/ListableBuilderTests.swift index 7374ea32d..896100405 100644 --- a/BlueprintUILists/Tests/ListableBuilderTests.swift +++ b/BlueprintUILists/Tests/ListableBuilderTests.swift @@ -84,29 +84,52 @@ class ListableBuilderTests : XCTestCase { var callCount : Int = 0 - let section = Section("1") { - EquatableElement { callCount += 1 } - EquatableElement { callCount += 1 }.item() - EquivalentElement { callCount += 1 } - EquivalentElement { callCount += 1 }.item() - } - - 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) + let sections : [Section] = [ + Section("1") { + EquatableElement { callCount += 1 } + EquatableElement { callCount += 1 }.item() + EquivalentElement { callCount += 1 } + EquivalentElement { callCount += 1 }.item() + }, + + Section("1") { section in + section += EquatableElement { callCount += 1 } + section.add(EquatableElement { callCount += 1 }.item()) + section += EquivalentElement { callCount += 1 } + section.add(EquivalentElement { callCount += 1 }.item()) + }, + + Section("1") { section in + section.add { + EquatableElement { callCount += 1 } + EquatableElement { callCount += 1 }.item() + EquivalentElement { callCount += 1 } + EquivalentElement { callCount += 1 }.item() + } + } + ] - XCTAssertTrue(equivalentItem2.anyIsEquivalent(to: equivalentItem2)) - XCTAssertEqual(callCount, 4) + 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() { From 1dcaa3d70d3af5407d1e8257ca61d457980a15d1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 26 Jul 2022 16:05:42 -0700 Subject: [PATCH 10/38] Additional self review and fixes --- .../Sources/Element+HeaderFooter.swift | 2 ++ BlueprintUILists/Sources/Element+Item.swift | 3 +++ BlueprintUILists/Sources/List.swift | 18 +++++++++--------- .../Sources/ListableBuilder+Element.swift | 10 +++++++--- ...wift => ListableBuilder+ElementTests.swift} | 4 ++-- ListableUI/Sources/ListProperties.swift | 2 +- ListableUI/Sources/ListableBuilder.swift | 10 +++++----- ListableUI/Sources/Section/Section.swift | 8 ++++---- .../Sources/SwipeActionsConfiguration.swift | 2 +- ListableUI/Tests/ListableBuilderTests.swift | 2 +- 10 files changed, 35 insertions(+), 26 deletions(-) rename BlueprintUILists/Tests/{ListableBuilderTests.swift => ListableBuilder+ElementTests.swift} (98%) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 19b440b02..b3d175ee6 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -41,6 +41,7 @@ extension Element { } +/// Ensures that the `Equatable` initializer for `WrappedHeaderFooterContent` is called. extension Element where Self:Equatable { public func headerFooter( @@ -54,6 +55,7 @@ extension Element where Self:Equatable { } +/// Ensures that the `IsEquivalentContent` initializer for `WrappedHeaderFooterContent` is called. extension Element where Self:IsEquivalentContent { public func headerFooter( diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index 924340614..3ea99020a 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -44,6 +44,8 @@ extension Element { } } + +/// Ensures that the `Equatable` initializer for `WrappedElementContent` is called. extension Element where Self:Equatable { public func item( @@ -61,6 +63,7 @@ extension Element where Self:Equatable { } +/// Ensures that the `IsEquivalentContent` initializer for `WrappedElementContent` is called. extension Element where Self:IsEquivalentContent { public func item( diff --git a/BlueprintUILists/Sources/List.swift b/BlueprintUILists/Sources/List.swift index ebeefa05e..5a86c6646 100644 --- a/BlueprintUILists/Sources/List.swift +++ b/BlueprintUILists/Sources/List.swift @@ -76,21 +76,21 @@ public struct List : Element public init( measurement : List.Measurement = .fillParent, configure : ListProperties.Configure = { _ in }, - @ListableBuilder
sections : () -> [Section], - @ListableOptionalBuilder containerHeader : () -> AnyHeaderFooterConvertible? = { nil }, - @ListableOptionalBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, - @ListableOptionalBuilder footer : () -> AnyHeaderFooterConvertible? = { nil }, - @ListableOptionalBuilder overscrollFooter : () -> AnyHeaderFooterConvertible? = { nil } + @ListableArrayBuilder
sections : () -> [Section], + @ListableValueBuilder containerHeader : () -> AnyHeaderFooterConvertible? = { nil }, + @ListableValueBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, + @ListableValueBuilder footer : () -> AnyHeaderFooterConvertible? = { nil }, + @ListableValueBuilder overscrollFooter : () -> AnyHeaderFooterConvertible? = { nil } ) { self.measurement = measurement var properties = ListProperties.default { $0.sections = sections() - $0.content.containerHeader = containerHeader() - $0.content.header = header() - $0.content.footer = footer() - $0.content.overscrollFooter = overscrollFooter() + $0.containerHeader = containerHeader() + $0.header = header() + $0.footer = footer() + $0.overscrollFooter = overscrollFooter() } configure(&properties) diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index fa7b89a98..0eb30b6dc 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -1,5 +1,5 @@ // -// ListableBuilder+Element.swift +// ListableArrayBuilder+Element.swift // BlueprintUILists // // Created by Kyle Van Essen on 7/24/22. @@ -24,16 +24,18 @@ import ListableUI /// Takes advantage of `@_disfavoredOverload` to avoid ambiguous method resolution with the default implementations. /// See more here: https://github.com/apple/swift/blob/main/docs/ReferenceGuides/UnderscoredAttributes.md#_disfavoredoverload /// -public extension ListableBuilder where ContentType == AnyItemConvertible { +public extension ListableArrayBuilder where ContentType == AnyItemConvertible { static func buildExpression(_ element: ElementType) -> Component { [element.item()] } + /// Ensures that the `Equatable`version of `.item()` is called. static func buildExpression(_ element: ElementType) -> Component where ElementType:Equatable { [element.item()] } + /// Ensures that the `IsEquivalentContent`version of `.item()` is called. static func buildExpression(_ element: ElementType) -> Component where ElementType:IsEquivalentContent { [element.item()] } @@ -44,16 +46,18 @@ public extension ListableBuilder where ContentType == AnyItemConvertible { } -public extension ListableOptionalBuilder where ContentType == AnyHeaderFooterConvertible { +public extension ListableValueBuilder where ContentType == AnyHeaderFooterConvertible { static func buildBlock(_ element: ElementType) -> ContentType { return element.headerFooter() } + /// Ensures that the `Equatable`version of `.headerFooter()` is called. static func buildBlock(_ element: ElementType) -> ContentType where ElementType:Equatable { return element.headerFooter() } + /// Ensures that the `IsEquivalentContent`version of `.headerFooter()` is called. static func buildBlock(_ element: ElementType) -> ContentType where ElementType:IsEquivalentContent { return element.headerFooter() } diff --git a/BlueprintUILists/Tests/ListableBuilderTests.swift b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift similarity index 98% rename from BlueprintUILists/Tests/ListableBuilderTests.swift rename to BlueprintUILists/Tests/ListableBuilder+ElementTests.swift index 896100405..ed4b446c2 100644 --- a/BlueprintUILists/Tests/ListableBuilderTests.swift +++ b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift @@ -1,5 +1,5 @@ // -// ListableBuilderTests.swift +// ListableBuilder+ElementTests.swift // BlueprintUILists // // Created by Kyle Van Essen on 7/24/22. @@ -9,7 +9,7 @@ import BlueprintUILists import XCTest -class ListableBuilderTests : XCTestCase { +class ListableBuilder_Element_Tests : XCTestCase { func test_builders() { diff --git a/ListableUI/Sources/ListProperties.swift b/ListableUI/Sources/ListProperties.swift index b0c4e9493..15062fd1d 100644 --- a/ListableUI/Sources/ListProperties.swift +++ b/ListableUI/Sources/ListProperties.swift @@ -248,7 +248,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 f4ffc79cc..2e34587fb 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 @@ -94,7 +94,7 @@ /// You provide a result builder in an API by specifying it as a method parameter, like so: /// /// ``` -/// init(@ListableBuilder thing : () -> SomeContent) { +/// init(@ListableValueBuilder thing : () -> SomeContent) { /// self.thing = thing() /// } /// ``` @@ -105,7 +105,7 @@ /// https://www.swiftbysundell.com/articles/deep-dive-into-swift-function-builders/ /// https://www.avanderlee.com/swift/result-builders/ /// -@resultBuilder public enum ListableOptionalBuilder { +@resultBuilder public enum ListableValueBuilder { public static func buildBlock() -> ContentType? { nil diff --git a/ListableUI/Sources/Section/Section.swift b/ListableUI/Sources/Section/Section.swift index 1b0518a1a..1bf395e4d 100644 --- a/ListableUI/Sources/Section/Section.swift +++ b/ListableUI/Sources/Section/Section.swift @@ -120,9 +120,9 @@ public struct Section _ identifier : IdentifierValue, layouts : SectionLayouts = .init(), reordering : SectionReordering = .init(), - @ListableBuilder items : () -> [AnyItemConvertible], - @ListableOptionalBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, - @ListableOptionalBuilder footer : () -> AnyHeaderFooterConvertible? = { nil } + @ListableArrayBuilder items : () -> [AnyItemConvertible], + @ListableValueBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, + @ListableValueBuilder footer : () -> AnyHeaderFooterConvertible? = { nil } ) { self.identifier = Identifier(identifier) @@ -211,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 4e927ca2e..1bb43e035 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/ListableBuilderTests.swift b/ListableUI/Tests/ListableBuilderTests.swift index 92d1cce0d..ced3443aa 100644 --- a/ListableUI/Tests/ListableBuilderTests.swift +++ b/ListableUI/Tests/ListableBuilderTests.swift @@ -159,7 +159,7 @@ class ListableBuilderTests : XCTestCase { } fileprivate func build( - @ListableBuilder using builder : () -> [Content] + @ListableArrayBuilder using builder : () -> [Content] ) -> [Content] { builder() From 966d397c8f7dc8760184ca5f22c9b2b3b6be0d6b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 26 Jul 2022 16:10:03 -0700 Subject: [PATCH 11/38] Update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 377fdad92..8319f5eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ } } ``` + +- Added `ListableValueBuilder`, a result builder for single-value results. ### Removed @@ -27,6 +29,8 @@ - Definition of `isEquivalent(to:)` has been moved to `IsEquivalentContent`. +- The `ListableBuilder` result builder is now `ListableArrayBuilder`. + ### Misc # Past Releases From 1b7d84a7e1582c416ee77f83f53bf4e0e2e2b124 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 27 Jul 2022 12:07:06 -0700 Subject: [PATCH 12/38] Rename: .item() to .listItem() --- .../Sources/Element+HeaderFooter.swift | 8 ++++---- BlueprintUILists/Sources/Element+Item.swift | 8 ++++---- .../Sources/ElementHeaderFooter.swift | 4 ++-- BlueprintUILists/Sources/ElementItem.swift | 4 ++-- .../Sources/ListableBuilder+Element.swift | 20 +++++++++---------- .../Sources/Section+Element.swift | 6 +++--- .../Tests/ListableBuilder+ElementTests.swift | 18 ++++++++--------- CHANGELOG.md | 2 +- 8 files changed, 35 insertions(+), 35 deletions(-) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index b3d175ee6..bb400b60c 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -19,7 +19,7 @@ extension Element { /// /// ```swift /// MyElement(...) - /// .headerFooter { header in + /// .listHeaderFooter { header in /// header.onTap = { ... } /// } /// ``` @@ -30,7 +30,7 @@ extension Element { /// hurt performance for longer lists (eg, more than 20 items): it will be re-measured for each content update. /// /// It is encouraged for these longer lists, you ensure your `Element` conforms to one of these protocols. - public func headerFooter( + public func listHeaderFooter( configure : (inout HeaderFooter>) -> () = { _ in } ) -> HeaderFooter> { HeaderFooter( @@ -44,7 +44,7 @@ extension Element { /// Ensures that the `Equatable` initializer for `WrappedHeaderFooterContent` is called. extension Element where Self:Equatable { - public func headerFooter( + public func listHeaderFooter( configure : (inout HeaderFooter>) -> () = { _ in } ) -> HeaderFooter> { HeaderFooter( @@ -58,7 +58,7 @@ extension Element where Self:Equatable { /// Ensures that the `IsEquivalentContent` initializer for `WrappedHeaderFooterContent` is called. extension Element where Self:IsEquivalentContent { - public func headerFooter( + public func listHeaderFooter( configure : (inout HeaderFooter>) -> () = { _ in } ) -> HeaderFooter> { HeaderFooter( diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index 3ea99020a..cfe1eb2fd 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -19,7 +19,7 @@ extension Element { /// /// ```swift /// MyElement(...) - /// .item(id: "my-provided-id") { item in + /// .listItem(id: "my-provided-id") { item in /// item.insertAndRemoveAnimations = .scaleUp /// } /// ``` @@ -30,7 +30,7 @@ extension Element { /// hurt performance for longer lists (eg, more than 20 items): it will be re-measured for each content update. /// /// It is encouraged for these longer lists, you ensure your `Element` conforms to one of these protocols. - public func item( + public func listItem( id : AnyHashable = ObjectIdentifier(Self.Type.self), configure : (inout Item>) -> () = { _ in } ) -> Item> { @@ -48,7 +48,7 @@ extension Element { /// Ensures that the `Equatable` initializer for `WrappedElementContent` is called. extension Element where Self:Equatable { - public func item( + public func listItem( id : AnyHashable = ObjectIdentifier(Self.Type.self), configure : (inout Item>) -> () = { _ in } ) -> Item> { @@ -66,7 +66,7 @@ extension Element where Self:Equatable { /// Ensures that the `IsEquivalentContent` initializer for `WrappedElementContent` is called. extension Element where Self:IsEquivalentContent { - public func item( + public func listItem( id : AnyHashable = ObjectIdentifier(Self.Type.self), configure : (inout Item>) -> () = { _ in } ) -> Item> { diff --git a/BlueprintUILists/Sources/ElementHeaderFooter.swift b/BlueprintUILists/Sources/ElementHeaderFooter.swift index 4a12e352a..e184ad1e6 100644 --- a/BlueprintUILists/Sources/ElementHeaderFooter.swift +++ b/BlueprintUILists/Sources/ElementHeaderFooter.swift @@ -10,7 +10,7 @@ import BlueprintUI /// -/// ⚠️ This method is soft-deprecated! Consider using `myElement.headerFooter(...)` instead. +/// ⚠️ 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. @@ -65,7 +65,7 @@ public func ElementHeaderFooter( } /// -/// ⚠️ This method is soft-deprecated! Consider using `myElement.headerFooter(...)` instead. +/// ⚠️ 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/ElementItem.swift b/BlueprintUILists/Sources/ElementItem.swift index fd52ea6f3..c1cf9a0de 100644 --- a/BlueprintUILists/Sources/ElementItem.swift +++ b/BlueprintUILists/Sources/ElementItem.swift @@ -10,7 +10,7 @@ import BlueprintUI /// -/// ⚠️ This method is soft-deprecated! Consider using `myElement.item(...)` instead. +/// ⚠️ 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. @@ -70,7 +70,7 @@ public func ElementItem( /// -/// ⚠️ This method is soft-deprecated! Consider using `myElement.item(...)` instead. +/// ⚠️ 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/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index 0eb30b6dc..698dd9bf6 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -27,17 +27,17 @@ import ListableUI public extension ListableArrayBuilder where ContentType == AnyItemConvertible { static func buildExpression(_ element: ElementType) -> Component { - [element.item()] + [element.listItem()] } - /// Ensures that the `Equatable`version of `.item()` is called. + /// Ensures that the `Equatable`version of `.listItem()` is called. static func buildExpression(_ element: ElementType) -> Component where ElementType:Equatable { - [element.item()] + [element.listItem()] } - /// Ensures that the `IsEquivalentContent`version of `.item()` is called. + /// Ensures that the `IsEquivalentContent`version of `.listItem()` is called. static func buildExpression(_ element: ElementType) -> Component where ElementType:IsEquivalentContent { - [element.item()] + [element.listItem()] } static func buildExpression(_ element: ElementType) -> Component where ElementType:ListElementNonConvertible { @@ -49,17 +49,17 @@ public extension ListableArrayBuilder where ContentType == AnyItemConvertible { public extension ListableValueBuilder where ContentType == AnyHeaderFooterConvertible { static func buildBlock(_ element: ElementType) -> ContentType { - return element.headerFooter() + return element.listHeaderFooter() } - /// Ensures that the `Equatable`version of `.headerFooter()` is called. + /// Ensures that the `Equatable`version of `.listHeaderFooter()` is called. static func buildBlock(_ element: ElementType) -> ContentType where ElementType:Equatable { - return element.headerFooter() + return element.listHeaderFooter() } - /// Ensures that the `IsEquivalentContent`version of `.headerFooter()` is called. + /// Ensures that the `IsEquivalentContent`version of `.listHeaderFooter()` is called. static func buildBlock(_ element: ElementType) -> ContentType where ElementType:IsEquivalentContent { - return element.headerFooter() + return element.listHeaderFooter() } static func buildBlock(_ element: ElementType) -> ContentType where ElementType:ListElementNonConvertible { diff --git a/BlueprintUILists/Sources/Section+Element.swift b/BlueprintUILists/Sources/Section+Element.swift index e371bdbfd..038a4c827 100644 --- a/BlueprintUILists/Sources/Section+Element.swift +++ b/BlueprintUILists/Sources/Section+Element.swift @@ -21,17 +21,17 @@ extension Section { /// ``` public mutating func add(_ element : ElementType) { - self.items.append(element.item()) + self.items.append(element.listItem()) } public mutating func add(_ element : ElementType) where ElementType:Equatable { - self.items.append(element.item()) + self.items.append(element.listItem()) } public mutating func add(_ element : ElementType) where ElementType:IsEquivalentContent { - self.items.append(element.item()) + self.items.append(element.listItem()) } /// Adds `Element` support when building a `Section`. diff --git a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift index ed4b446c2..a30908044 100644 --- a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift +++ b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift @@ -52,7 +52,7 @@ class ListableBuilder_Element_Tests : XCTestCase { } header: { Element1() } footer: { - Element2().headerFooter() + Element2().listHeaderFooter() } Section("2") { section in @@ -87,24 +87,24 @@ class ListableBuilder_Element_Tests : XCTestCase { let sections : [Section] = [ Section("1") { EquatableElement { callCount += 1 } - EquatableElement { callCount += 1 }.item() + EquatableElement { callCount += 1 }.listItem() EquivalentElement { callCount += 1 } - EquivalentElement { callCount += 1 }.item() + EquivalentElement { callCount += 1 }.listItem() }, Section("1") { section in section += EquatableElement { callCount += 1 } - section.add(EquatableElement { callCount += 1 }.item()) + section.add(EquatableElement { callCount += 1 }.listItem()) section += EquivalentElement { callCount += 1 } - section.add(EquivalentElement { callCount += 1 }.item()) + section.add(EquivalentElement { callCount += 1 }.listItem()) }, Section("1") { section in section.add { EquatableElement { callCount += 1 } - EquatableElement { callCount += 1 }.item() + EquatableElement { callCount += 1 }.listItem() EquivalentElement { callCount += 1 } - EquivalentElement { callCount += 1 }.item() + EquivalentElement { callCount += 1 }.listItem() } } ] @@ -141,7 +141,7 @@ class ListableBuilder_Element_Tests : XCTestCase { } header: { EquatableElement { callCount += 1 } } footer: { - EquatableElement { callCount += 1 }.headerFooter() + EquatableElement { callCount += 1 }.listHeaderFooter() } let equivalentSection = Section("1") { @@ -149,7 +149,7 @@ class ListableBuilder_Element_Tests : XCTestCase { } header: { EquivalentElement { callCount += 1 } } footer: { - EquivalentElement { callCount += 1 }.headerFooter() + EquivalentElement { callCount += 1 }.listHeaderFooter() } let equatableItem1 = equatableSection.header!.asAnyHeaderFooter() diff --git a/CHANGELOG.md b/CHANGELOG.md index 8319f5eac..3f16b9108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ MyElement() // A Blueprint Element AnotherElement() // A Blueprint Element AnotherElement() - .item(id: "my-specified-id") { item in + .listItem(id: "my-specified-id") { item in item.insertAndRemoveAnimations = .scaleUp } } From 5b167f1027a9150880f3822785b0ea4dd9dd19e5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 28 Jul 2022 13:51:06 -0700 Subject: [PATCH 13/38] Implement automatic isEquivalent checking for Elements --- .../Sources/Element+HeaderFooter.swift | 7 +- BlueprintUILists/Sources/Element+Item.swift | 21 +-- .../Tests/Element+ItemTests.swift | 17 +++ .../Tests/ListableBuilder+ElementTests.swift | 10 ++ .../Sources/IsEquivalent/AlwaysEqual.swift | 22 ++++ .../CompareEquatableProperties.swift | 122 ++++++++++++++++++ .../CompareEquatablePropertiesTests.swift | 100 ++++++++++++++ 7 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 BlueprintUILists/Tests/Element+ItemTests.swift create mode 100644 ListableUI/Sources/IsEquivalent/AlwaysEqual.swift create mode 100644 ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift create mode 100644 ListableUI/Tests/CompareEquatablePropertiesTests.swift diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index bb400b60c..455cbd246 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -6,6 +6,7 @@ // import BlueprintUI +@_spi(ListableInternal) import ListableUI @@ -78,7 +79,11 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF init(represented : ElementType) { self.represented = represented - self.isEquivalent = { _, _ in false } + self.isEquivalent = { + /// Our default implementation compares the `Equatable` properties of the + /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. + isEqualComparingEquatableProperties($0.represented, $1.represented) + } } init(represented : ElementType) where ElementType:Equatable { diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index cfe1eb2fd..8b0846ed6 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -6,6 +6,7 @@ // import BlueprintUI +@_spi(ListableInternal) import ListableUI @@ -31,7 +32,7 @@ extension Element { /// /// It is encouraged for these longer lists, you ensure your `Element` conforms to one of these protocols. public func listItem( - id : AnyHashable = ObjectIdentifier(Self.Type.self), + id : AnyHashable? = nil, configure : (inout Item>) -> () = { _ in } ) -> Item> { Item( @@ -49,7 +50,7 @@ extension Element { extension Element where Self:Equatable { public func listItem( - id : AnyHashable = ObjectIdentifier(Self.Type.self), + id : AnyHashable? = nil, configure : (inout Item>) -> () = { _ in } ) -> Item> { Item( @@ -67,7 +68,7 @@ extension Element where Self:Equatable { extension Element where Self:IsEquivalentContent { public func listItem( - id : AnyHashable = ObjectIdentifier(Self.Type.self), + id : AnyHashable? = nil, configure : (inout Item>) -> () = { _ in } ) -> Item> { Item( @@ -83,24 +84,28 @@ extension Element where Self:IsEquivalentContent { public struct WrappedElementContent : BlueprintItemContent { - public let identifierValue: AnyHashable + public let identifierValue: AnyHashable? public let represented : ElementType private let isEquivalent : (Self, Self) -> Bool init( - identifierValue: AnyHashable, + identifierValue: AnyHashable?, represented: ElementType ) { self.represented = represented self.identifierValue = identifierValue - self.isEquivalent = { _, _ in false } + self.isEquivalent = { + /// Our default implementation compares the `Equatable` properties of the + /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. + isEqualComparingEquatableProperties($0.represented, $1.represented) + } } init( - identifierValue: AnyHashable, + identifierValue: AnyHashable?, represented: ElementType ) where ElementType:Equatable { self.represented = represented @@ -112,7 +117,7 @@ public struct WrappedElementContent : BlueprintItemContent } init( - identifierValue: AnyHashable, + identifierValue: AnyHashable?, represented: ElementType ) where ElementType:IsEquivalentContent { self.represented = represented diff --git a/BlueprintUILists/Tests/Element+ItemTests.swift b/BlueprintUILists/Tests/Element+ItemTests.swift new file mode 100644 index 000000000..67a23a73b --- /dev/null +++ b/BlueprintUILists/Tests/Element+ItemTests.swift @@ -0,0 +1,17 @@ +// +// Element+ItemTests.swift +// BlueprintUILists-Unit-Tests +// +// Created by Kyle Van Essen on 7/27/22. +// + +import XCTest +import BlueprintUILists + + +class Element_ItemTests : XCTestCase { + + +} + + diff --git a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift index a30908044..0c8f60842 100644 --- a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift +++ b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift @@ -215,6 +215,16 @@ fileprivate struct EquatableElement : ProxyElement, Equatable { } +fileprivate struct EquatableWithPropertyWrapperElement : ProxyElement, Equatable { + + @AlwaysEqual var callback : () -> () + + var elementRepresentation: Element { + Empty() + } +} + + fileprivate struct EquivalentElement : ProxyElement, IsEquivalentContent { var calledIsEquivalent : () -> () diff --git a/ListableUI/Sources/IsEquivalent/AlwaysEqual.swift b/ListableUI/Sources/IsEquivalent/AlwaysEqual.swift new file mode 100644 index 000000000..d5e06206e --- /dev/null +++ b/ListableUI/Sources/IsEquivalent/AlwaysEqual.swift @@ -0,0 +1,22 @@ +// +// AlwaysEqual.swift +// ListableUI +// +// Created by Kyle Van Essen on 7/27/22. +// + +import Foundation + + +@propertyWrapper public struct AlwaysEqual : Equatable { + + public var wrappedValue : Value + + public init(wrappedValue : Value) { + self.wrappedValue = wrappedValue + } + + public static func == (lhs : Self, rhs : Self) -> Bool { + true + } +} diff --git a/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift b/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift new file mode 100644 index 000000000..aff40732c --- /dev/null +++ b/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift @@ -0,0 +1,122 @@ +// +// CompareEquatableProperties.swift +// ListableUI +// +// Created by Kyle Van Essen on 7/28/22. +// + +import Foundation + + +/// Compares if the `Equatable` properies on two objects are equal, even if the object itself is not `Equatable`. +/// +/// ## Example +/// For the following struct, the `title`, `detail` and `count` properties will be compared. The +/// `nonEquatable` and `closure` parameters will be ignored. +/// +/// ``` +/// fileprivate struct MyStruct { +/// +/// var title : String +/// var detail : String? +/// var count : Int +/// +/// var nonEquatable: NonEquatableValue +/// +/// var closure : () -> () +/// } +/// ``` +/// +/// Inspired by https://github.com/objcio/S01E264-comparing-views/blob/master/Sources/NotSwiftUIState/AnyEquatable.swift +/// +@_spi(ListableInternal) +public func isEqualComparingEquatableProperties(_ lhs : Any, _ rhs : Any) -> Bool { + + guard type(of: lhs) == type(of: rhs) else { + return false + } + + let lhs = Mirror(reflecting: lhs) + + guard lhs.children.isEmpty == false else { + return true + } + + let rhs = Mirror(reflecting: rhs) + + for (prop1, prop2) in zip(lhs.children, rhs.children) { + + guard isEquatableValue(prop1.value) else { + continue + } + + guard isEqual(prop1.value, prop2.value) else { + return false + } + } + + + + return true +} + + +private func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { + + func check(value: Value) -> Bool { + + if let typeInfo = Wrapped.self as? AnyEquatable.Type { + return typeInfo.isEqual(lhs: lhs, rhs: rhs) + } + + return false + } + + /// This is the magic part of the whole process. Through `_openExistential`, + /// Swift will take the `Any` type (the existential type), and call the provided `body` + /// with the existential converted to the contained type. Because we have no constraint + /// on the contained type (just a `LHS` generic), we can then check if the contained type + /// will conform to `AnyEquatable`. + /// + /// ``` + /// public func _openExistential( + /// _ existential: ExistentialType, + /// do body: (ContainedType) throws -> ResultType + /// ) rethrows -> ResultType + /// ``` + /// + /// https://github.com/apple/swift/blob/main/stdlib/public/core/Builtin.swift#L1005 + + return _openExistential(lhs, do: check) +} + + +private func isEquatableValue(_ value: Any) -> Bool { + + func check(value: Value) -> Bool { + Wrapped.self is AnyEquatable.Type + } + + return _openExistential(value, do: check) +} + + +private protocol AnyEquatable { + static func isEqual(lhs: Any, rhs: Any) -> Bool +} + + +private enum Wrapped {} + + +extension Wrapped: AnyEquatable where Value: Equatable { + + static func isEqual(lhs: Any, rhs: Any) -> Bool { + + guard let lhs = lhs as? Value, let rhs = rhs as? Value else { + return false + } + + return lhs == rhs + } +} diff --git a/ListableUI/Tests/CompareEquatablePropertiesTests.swift b/ListableUI/Tests/CompareEquatablePropertiesTests.swift new file mode 100644 index 000000000..5507b7a5f --- /dev/null +++ b/ListableUI/Tests/CompareEquatablePropertiesTests.swift @@ -0,0 +1,100 @@ +// +// CompareEquatablePropertiesTests.swift +// ListableUI-Unit-Tests +// +// Created by Kyle Van Essen on 7/28/22. +// + +@_spi(ListableInternal) import ListableUI +import XCTest + + +class CompareEquatablePropertiesTests : XCTestCase { + + func test_compare() { + + XCTAssertTrue( + isEqualComparingEquatableProperties( + TestValue( + title: "A Title", + detail: "Some Detail", + count: 10, + closure: {} + ), + TestValue( + title: "A Title", + detail: "Some Detail", + count: 10, + closure: {} + ) + ) + ) + + XCTAssertFalse( + isEqualComparingEquatableProperties( + TestValue( + title: "A Different Title", + detail: "Some Detail", + count: 10, + closure: {} + ), + TestValue( + title: "A Title", + detail: "Some Detail", + count: 10, + closure: {} + ) + ) + ) + + XCTAssertTrue( + isEqualComparingEquatableProperties( + TestValueWithNoEquatableProperties(), + TestValueWithNoEquatableProperties() + ) + ) + } + + func test_performance() { + determineAverage(for: 1.0) { + _ = isEqualComparingEquatableProperties( + TestValue( + title: "A Title", + count: 10, + closure: {} + ), + TestValue( + title: "A Title", + count: 10, + closure: {} + ) + ) + } + } +} + + +fileprivate struct TestValue { + + var title : String + var detail : String? + var count : Int + + var nonEquatable: NonEquatableValue = .init(value: "An inner string") + + var closure : () -> () +} + + +fileprivate struct NonEquatableValue { + + var value : Any +} + + +fileprivate struct TestValueWithNoEquatableProperties { + + var closure1 : () -> () = {} + var closure2 : () -> () = {} + +} From 057e1c9c8985ddcdc8a42c5222843f6eb7936ff5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 28 Jul 2022 17:30:51 -0700 Subject: [PATCH 14/38] Docs, add default IsEquivalentContent implementation. --- .../Sources/Element+HeaderFooter.swift | 6 ------ BlueprintUILists/Sources/Element+Item.swift | 6 ------ .../IsEquivalent/CompareEquatableProperties.swift | 15 +++++++++++++-- .../IsEquivalent/IsEquivalentContent.swift | 11 +++++++++++ 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 455cbd246..75daf4144 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -25,12 +25,6 @@ extension Element { /// } /// ``` /// - /// ## ⚠️ Performance Considerations - /// Unless your `Element` conforms to `Equatable` or `IsEquivalentContent`, - /// it will return `false` for `isEquivalent` for each content update, which can dramatically - /// hurt performance for longer lists (eg, more than 20 items): it will be re-measured for each content update. - /// - /// It is encouraged for these longer lists, you ensure your `Element` conforms to one of these protocols. public func listHeaderFooter( configure : (inout HeaderFooter>) -> () = { _ in } ) -> HeaderFooter> { diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index 8b0846ed6..7c5d05c1f 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -25,12 +25,6 @@ extension Element { /// } /// ``` /// - /// ## ⚠️ Performance Considerations - /// Unless your `Element` conforms to `Equatable` or `IsEquivalentContent`, - /// it will return `false` for `isEquivalent` for each content update, which can dramatically - /// hurt performance for longer lists (eg, more than 20 items): it will be re-measured for each content update. - /// - /// It is encouraged for these longer lists, you ensure your `Element` conforms to one of these protocols. public func listItem( id : AnyHashable? = nil, configure : (inout Item>) -> () = { _ in } diff --git a/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift b/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift index aff40732c..23d6c54d4 100644 --- a/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift @@ -32,35 +32,46 @@ import Foundation @_spi(ListableInternal) public func isEqualComparingEquatableProperties(_ lhs : Any, _ rhs : Any) -> Bool { + // 1) We can't compare values unless the objects are the same type. + guard type(of: lhs) == type(of: rhs) else { return false } let lhs = Mirror(reflecting: lhs) + // 2) Values with no fields are always Equal. + guard lhs.children.isEmpty == false else { return true } let rhs = Mirror(reflecting: rhs) + // 3) Enumerate each property, by enumerating the `Mirrors`. + for (prop1, prop2) in zip(lhs.children, rhs.children) { + // 3a) Skip any values which are not themselves `Equatable`. + guard isEquatableValue(prop1.value) else { continue } + // 3b) Finally, compare the underlying values. + guard isEqual(prop1.value, prop2.value) else { return false } } - + // 4) All `Equatable` properties were equal, so we're equal. return true } +/// Checks if the two provided values are the same type and Equatable. private func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { func check(value: Value) -> Bool { @@ -90,7 +101,7 @@ private func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { return _openExistential(lhs, do: check) } - +/// Checks if the provided `value` is `Equatable`. private func isEquatableValue(_ value: Any) -> Bool { func check(value: Value) -> Bool { diff --git a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift index 3617abb3f..414325e75 100644 --- a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift +++ b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift @@ -82,6 +82,17 @@ public protocol IsEquivalentContent { } +public extension IsEquivalentContent +{ + /// Our default implementation compares the `Equatable` properties of the + /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. + /// + func isEquivalent(to other : Self) -> Bool { + isEqualComparingEquatableProperties(self, other) + } +} + + public extension IsEquivalentContent where Self:Equatable { /// If your content is `Equatable`, `isEquivalent` is based on the `Equatable` implementation. From b60f091d0b5627368c99be8688e078e466cdab05 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 28 Jul 2022 17:38:27 -0700 Subject: [PATCH 15/38] Rename to areEquatablePropertiesEqual --- BlueprintUILists/Sources/Element+HeaderFooter.swift | 2 +- BlueprintUILists/Sources/Element+Item.swift | 2 +- .../Sources/IsEquivalent/CompareEquatableProperties.swift | 2 +- ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift | 2 +- ListableUI/Tests/CompareEquatablePropertiesTests.swift | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 75daf4144..89c95cd4b 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -76,7 +76,7 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF self.isEquivalent = { /// Our default implementation compares the `Equatable` properties of the /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. - isEqualComparingEquatableProperties($0.represented, $1.represented) + areEquatablePropertiesEqual($0.represented, $1.represented) } } diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index 7c5d05c1f..03ff97f94 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -94,7 +94,7 @@ public struct WrappedElementContent : BlueprintItemContent self.isEquivalent = { /// Our default implementation compares the `Equatable` properties of the /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. - isEqualComparingEquatableProperties($0.represented, $1.represented) + areEquatablePropertiesEqual($0.represented, $1.represented) } } diff --git a/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift b/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift index 23d6c54d4..f51795394 100644 --- a/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift @@ -30,7 +30,7 @@ import Foundation /// Inspired by https://github.com/objcio/S01E264-comparing-views/blob/master/Sources/NotSwiftUIState/AnyEquatable.swift /// @_spi(ListableInternal) -public func isEqualComparingEquatableProperties(_ lhs : Any, _ rhs : Any) -> Bool { +public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> Bool { // 1) We can't compare values unless the objects are the same type. diff --git a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift index 414325e75..d34f21f6e 100644 --- a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift +++ b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift @@ -88,7 +88,7 @@ public extension IsEquivalentContent /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. /// func isEquivalent(to other : Self) -> Bool { - isEqualComparingEquatableProperties(self, other) + areEquatablePropertiesEqual(self, other) } } diff --git a/ListableUI/Tests/CompareEquatablePropertiesTests.swift b/ListableUI/Tests/CompareEquatablePropertiesTests.swift index 5507b7a5f..702e7f83a 100644 --- a/ListableUI/Tests/CompareEquatablePropertiesTests.swift +++ b/ListableUI/Tests/CompareEquatablePropertiesTests.swift @@ -14,7 +14,7 @@ class CompareEquatablePropertiesTests : XCTestCase { func test_compare() { XCTAssertTrue( - isEqualComparingEquatableProperties( + areEquatablePropertiesEqual( TestValue( title: "A Title", detail: "Some Detail", @@ -31,7 +31,7 @@ class CompareEquatablePropertiesTests : XCTestCase { ) XCTAssertFalse( - isEqualComparingEquatableProperties( + areEquatablePropertiesEqual( TestValue( title: "A Different Title", detail: "Some Detail", @@ -48,7 +48,7 @@ class CompareEquatablePropertiesTests : XCTestCase { ) XCTAssertTrue( - isEqualComparingEquatableProperties( + areEquatablePropertiesEqual( TestValueWithNoEquatableProperties(), TestValueWithNoEquatableProperties() ) @@ -57,7 +57,7 @@ class CompareEquatablePropertiesTests : XCTestCase { func test_performance() { determineAverage(for: 1.0) { - _ = isEqualComparingEquatableProperties( + _ = areEquatablePropertiesEqual( TestValue( title: "A Title", count: 10, From f39e3b4a32f4b2182cb96b695b1be8c5965be1cc Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 28 Jul 2022 21:54:13 -0700 Subject: [PATCH 16/38] Update comments and default implementations --- .../Sources/Element+HeaderFooter.swift | 2 +- BlueprintUILists/Sources/Element+Item.swift | 2 +- ...operties.swift => CompareProperties.swift} | 58 +++++++++++++------ .../IsEquivalent/IsEquivalentContent.swift | 49 +++++++++++++++- ...sts.swift => ComparePropertiesTests.swift} | 42 +++++++++++--- 5 files changed, 122 insertions(+), 31 deletions(-) rename ListableUI/Sources/IsEquivalent/{CompareEquatableProperties.swift => CompareProperties.swift} (75%) rename ListableUI/Tests/{CompareEquatablePropertiesTests.swift => ComparePropertiesTests.swift} (66%) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 89c95cd4b..4acb5818e 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -76,7 +76,7 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF self.isEquivalent = { /// Our default implementation compares the `Equatable` properties of the /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. - areEquatablePropertiesEqual($0.represented, $1.represented) + defaultIsEquivalentImplementation($0.represented, $1.represented) } } diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index 03ff97f94..5ee8ab35d 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -94,7 +94,7 @@ public struct WrappedElementContent : BlueprintItemContent self.isEquivalent = { /// Our default implementation compares the `Equatable` properties of the /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. - areEquatablePropertiesEqual($0.represented, $1.represented) + defaultIsEquivalentImplementation($0.represented, $1.represented) } } diff --git a/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift b/ListableUI/Sources/IsEquivalent/CompareProperties.swift similarity index 75% rename from ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift rename to ListableUI/Sources/IsEquivalent/CompareProperties.swift index f51795394..efb937938 100644 --- a/ListableUI/Sources/IsEquivalent/CompareEquatableProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareProperties.swift @@ -1,5 +1,5 @@ // -// CompareEquatableProperties.swift +// CompareProperties.swift // ListableUI // // Created by Kyle Van Essen on 7/28/22. @@ -8,7 +8,7 @@ import Foundation -/// Compares if the `Equatable` properies on two objects are equal, even if the object itself is not `Equatable`. +/// Checks if the `Equatable` properies on two objects are equal, even if the object itself is not `Equatable`. /// /// ## Example /// For the following struct, the `title`, `detail` and `count` properties will be compared. The @@ -30,12 +30,12 @@ import Foundation /// Inspired by https://github.com/objcio/S01E264-comparing-views/blob/master/Sources/NotSwiftUIState/AnyEquatable.swift /// @_spi(ListableInternal) -public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> Bool { +public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> AreEquatablePropertiesEqualResult { // 1) We can't compare values unless the objects are the same type. guard type(of: lhs) == type(of: rhs) else { - return false + return .notEqual } let lhs = Mirror(reflecting: lhs) @@ -43,13 +43,15 @@ public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> Bool { // 2) Values with no fields are always Equal. guard lhs.children.isEmpty == false else { - return true + return .equal } let rhs = Mirror(reflecting: rhs) // 3) Enumerate each property, by enumerating the `Mirrors`. + var hadEquatableProperty = false + for (prop1, prop2) in zip(lhs.children, rhs.children) { // 3a) Skip any values which are not themselves `Equatable`. @@ -58,16 +60,34 @@ public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> Bool { continue } + hadEquatableProperty = true + // 3b) Finally, compare the underlying values. guard isEqual(prop1.value, prop2.value) else { - return false + return .notEqual } } - // 4) All `Equatable` properties were equal, so we're equal. + if hadEquatableProperty { + // 4a) All `Equatable` properties were equal, so we're equal. + return .equal + } else { + // 4b) We found no `Equatable` properties – behavior is undefined. + return .error(.noEquatableProperties) + } +} + + +@_spi(ListableInternal) +public enum AreEquatablePropertiesEqualResult : Equatable { + case equal + case notEqual + case error(Error) - return true + public enum Error { + case noEquatableProperties + } } @@ -75,18 +95,17 @@ public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> Bool { private func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { func check(value: Value) -> Bool { - if let typeInfo = Wrapped.self as? AnyEquatable.Type { return typeInfo.isEqual(lhs: lhs, rhs: rhs) + } else { + return false } - - return false } /// This is the magic part of the whole process. Through `_openExistential`, /// Swift will take the `Any` type (the existential type), and call the provided `body` /// with the existential converted to the contained type. Because we have no constraint - /// on the contained type (just a `LHS` generic), we can then check if the contained type + /// on the contained type (just a `Value` generic), we can then check if the contained type /// will conform to `AnyEquatable`. /// /// ``` @@ -101,6 +120,7 @@ private func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { return _openExistential(lhs, do: check) } + /// Checks if the provided `value` is `Equatable`. private func isEquatableValue(_ value: Any) -> Bool { @@ -112,17 +132,12 @@ private func isEquatableValue(_ value: Any) -> Bool { } -private protocol AnyEquatable { - static func isEqual(lhs: Any, rhs: Any) -> Bool -} - - -private enum Wrapped {} +fileprivate enum Wrapped {} extension Wrapped: AnyEquatable where Value: Equatable { - static func isEqual(lhs: Any, rhs: Any) -> Bool { + fileprivate static func isEqual(lhs: Any, rhs: Any) -> Bool { guard let lhs = lhs as? Value, let rhs = rhs as? Value else { return false @@ -131,3 +146,8 @@ extension Wrapped: AnyEquatable where Value: Equatable { return lhs == rhs } } + + +private protocol AnyEquatable { + static func isEqual(lhs: Any, rhs: Any) -> Bool +} diff --git a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift index d34f21f6e..c0cb7754c 100644 --- a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift +++ b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift @@ -11,8 +11,16 @@ 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. /// -/// Please see ``IsEquivalentContent/isEquivalent(to:)-15tcq`` for a full discussion of -/// correct (and incorrect) implementation and usages. +/// ## Note +/// You should rarely need to implement ``IsEquivalentContent/isEquivalent(to:)-15tcq`` +/// yourself. By default, Listable will... +/// - For regular objects, compare all `Equatable` properties on your object to see if they changed. +/// - For `Equatable` objects, check to see if the object is equal. +/// +/// If you do need to implement this method yourself (eg, your object has no equatable properties, +/// or cannot conform to `Equatable`, see ``IsEquivalentContent/isEquivalent(to:)-15tcq`` +/// for a full discussion of correct (and incorrect) implementations. +/// public protocol IsEquivalentContent { /// @@ -88,7 +96,7 @@ public extension IsEquivalentContent /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. /// func isEquivalent(to other : Self) -> Bool { - areEquatablePropertiesEqual(self, other) + defaultIsEquivalentImplementation(self, other) } } @@ -100,3 +108,38 @@ public extension IsEquivalentContent where Self:Equatable self == other } } + + +@_spi(ListableInternal) +public func defaultIsEquivalentImplementation(_ lhs : Value, _ rhs : Value) -> Bool { + let result = areEquatablePropertiesEqual(lhs, rhs) + + switch result { + case .equal: + return true + + case .notEqual: + return false + case .error(let error): + + switch error { + case .noEquatableProperties: + assertionFailure( + """ + DEBUG FAILURE: The default `isEquivalent(to:)` implementation could not find any `Equatable` properties \ + on \(Value.self). In production / release versions, `isEquivalent(to:)` will always return false, which + will affect performance. You should implement `isEquivalent(to:)` and check the relevant + sub-properties to provide proper conformance: + + ``` + func isEquivalent(to other : Self) -> Bool { + property.subProperty == other.property.subProperty && ... + } + ``` + """ + ) + } + + return false + } +} diff --git a/ListableUI/Tests/CompareEquatablePropertiesTests.swift b/ListableUI/Tests/ComparePropertiesTests.swift similarity index 66% rename from ListableUI/Tests/CompareEquatablePropertiesTests.swift rename to ListableUI/Tests/ComparePropertiesTests.swift index 702e7f83a..ec3a85cca 100644 --- a/ListableUI/Tests/CompareEquatablePropertiesTests.swift +++ b/ListableUI/Tests/ComparePropertiesTests.swift @@ -1,5 +1,5 @@ // -// CompareEquatablePropertiesTests.swift +// ComparePropertiesTests.swift // ListableUI-Unit-Tests // // Created by Kyle Van Essen on 7/28/22. @@ -9,48 +9,67 @@ import XCTest -class CompareEquatablePropertiesTests : XCTestCase { +class ComparePropertiesTests : XCTestCase { func test_compare() { - XCTAssertTrue( + XCTAssertEqual( + .equal, areEquatablePropertiesEqual( TestValue( title: "A Title", detail: "Some Detail", count: 10, + enumValue: .foo, closure: {} ), TestValue( title: "A Title", detail: "Some Detail", count: 10, + enumValue: .foo, closure: {} ) ) ) - XCTAssertFalse( + XCTAssertEqual( + .notEqual, areEquatablePropertiesEqual( TestValue( title: "A Different Title", detail: "Some Detail", count: 10, + enumValue: .foo, closure: {} ), TestValue( title: "A Title", detail: "Some Detail", count: 10, + enumValue: .foo, closure: {} ) ) ) - XCTAssertTrue( + XCTAssertEqual( + .notEqual, areEquatablePropertiesEqual( - TestValueWithNoEquatableProperties(), - TestValueWithNoEquatableProperties() + TestValue( + title: "A Title", + detail: "Some Detail", + count: 10, + enumValue: .foo, + closure: {} + ), + TestValue( + title: "A Title", + detail: "Some Detail", + count: 10, + enumValue: .bar("A String"), + closure: {} + ) ) ) } @@ -61,11 +80,13 @@ class CompareEquatablePropertiesTests : XCTestCase { TestValue( title: "A Title", count: 10, + enumValue: .foo, closure: {} ), TestValue( title: "A Title", count: 10, + enumValue: .foo, closure: {} ) ) @@ -80,9 +101,16 @@ fileprivate struct TestValue { var detail : String? var count : Int + var enumValue : EnumValue + var nonEquatable: NonEquatableValue = .init(value: "An inner string") var closure : () -> () + + enum EnumValue : Equatable { + case foo + case bar(String) + } } From 2d87e9525b9c92f445e6d910dd01ec7124ffeac9 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 28 Jul 2022 22:03:44 -0700 Subject: [PATCH 17/38] Comment cleanup --- .../Sources/IsEquivalent/IsEquivalentContent.swift | 9 +++++---- ListableUI/Tests/ComparePropertiesTests.swift | 8 ++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift index c0cb7754c..75d9a4d6f 100644 --- a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift +++ b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift @@ -120,20 +120,21 @@ public func defaultIsEquivalentImplementation(_ lhs : Value, _ rhs : Valu case .notEqual: return false + case .error(let error): switch error { case .noEquatableProperties: assertionFailure( """ - DEBUG FAILURE: The default `isEquivalent(to:)` implementation could not find any `Equatable` properties \ - on \(Value.self). In production / release versions, `isEquivalent(to:)` will always return false, which - will affect performance. You should implement `isEquivalent(to:)` and check the relevant + FAILURE: The default `isEquivalent(to:)` implementation could not find any `Equatable` properties \ + on \(Value.self). In release versions, `isEquivalent(to:)` will always return false, which \ + will affect performance. You should implement `isEquivalent(to:)` and check the relevant \ sub-properties to provide proper conformance: ``` func isEquivalent(to other : Self) -> Bool { - property.subProperty == other.property.subProperty && ... + myVar.subProperty == other.myVar.subProperty && ... } ``` """ diff --git a/ListableUI/Tests/ComparePropertiesTests.swift b/ListableUI/Tests/ComparePropertiesTests.swift index ec3a85cca..156a20cd0 100644 --- a/ListableUI/Tests/ComparePropertiesTests.swift +++ b/ListableUI/Tests/ComparePropertiesTests.swift @@ -72,6 +72,14 @@ class ComparePropertiesTests : XCTestCase { ) ) ) + + XCTAssertEqual( + .error(.noEquatableProperties), + areEquatablePropertiesEqual( + TestValueWithNoEquatableProperties(), + TestValueWithNoEquatableProperties() + ) + ) } func test_performance() { From d15b03699d82068491809aa8cd8fdc5d28f79025 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 28 Jul 2022 22:07:44 -0700 Subject: [PATCH 18/38] Remove Property Wrapper --- .../Sources/IsEquivalent/AlwaysEqual.swift | 22 ------------------- 1 file changed, 22 deletions(-) delete mode 100644 ListableUI/Sources/IsEquivalent/AlwaysEqual.swift diff --git a/ListableUI/Sources/IsEquivalent/AlwaysEqual.swift b/ListableUI/Sources/IsEquivalent/AlwaysEqual.swift deleted file mode 100644 index d5e06206e..000000000 --- a/ListableUI/Sources/IsEquivalent/AlwaysEqual.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// AlwaysEqual.swift -// ListableUI -// -// Created by Kyle Van Essen on 7/27/22. -// - -import Foundation - - -@propertyWrapper public struct AlwaysEqual : Equatable { - - public var wrappedValue : Value - - public init(wrappedValue : Value) { - self.wrappedValue = wrappedValue - } - - public static func == (lhs : Self, rhs : Self) -> Bool { - true - } -} From 090e38a65b830e24bdb75b3556633e9bfec720a4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 Jul 2022 11:11:41 -0700 Subject: [PATCH 19/38] After chatting with Tim, update how we traverse properties. --- .../Sources/Element+HeaderFooter.swift | 2 - BlueprintUILists/Sources/Element+Item.swift | 2 - .../Tests/ListableBuilder+ElementTests.swift | 10 --- .../IsEquivalent/CompareProperties.swift | 84 ++++++++++++++----- .../IsEquivalent/IsEquivalentContent.swift | 5 ++ ListableUI/Tests/ComparePropertiesTests.swift | 46 ++++++++++ 6 files changed, 116 insertions(+), 33 deletions(-) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 4acb5818e..50fbedf78 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -74,8 +74,6 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF self.represented = represented self.isEquivalent = { - /// Our default implementation compares the `Equatable` properties of the - /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. defaultIsEquivalentImplementation($0.represented, $1.represented) } } diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index 5ee8ab35d..1228b433c 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -92,8 +92,6 @@ public struct WrappedElementContent : BlueprintItemContent self.identifierValue = identifierValue self.isEquivalent = { - /// Our default implementation compares the `Equatable` properties of the - /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. defaultIsEquivalentImplementation($0.represented, $1.represented) } } diff --git a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift index 0c8f60842..a30908044 100644 --- a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift +++ b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift @@ -215,16 +215,6 @@ fileprivate struct EquatableElement : ProxyElement, Equatable { } -fileprivate struct EquatableWithPropertyWrapperElement : ProxyElement, Equatable { - - @AlwaysEqual var callback : () -> () - - var elementRepresentation: Element { - Empty() - } -} - - fileprivate struct EquivalentElement : ProxyElement, IsEquivalentContent { var calledIsEquivalent : () -> () diff --git a/ListableUI/Sources/IsEquivalent/CompareProperties.swift b/ListableUI/Sources/IsEquivalent/CompareProperties.swift index efb937938..df1fb567e 100644 --- a/ListableUI/Sources/IsEquivalent/CompareProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareProperties.swift @@ -12,7 +12,8 @@ import Foundation /// /// ## Example /// For the following struct, the `title`, `detail` and `count` properties will be compared. The -/// `nonEquatable` and `closure` parameters will be ignored. +/// `closure` will be ignored, and the properties of `nonEquatable` will be traversed to look +/// for `Equatable` sub-properties (and so on). /// /// ``` /// fileprivate struct MyStruct { @@ -32,48 +33,76 @@ import Foundation @_spi(ListableInternal) public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> AreEquatablePropertiesEqualResult { - // 1) We can't compare values unless the objects are the same type. + // We can't compare values unless the objects are the same type. guard type(of: lhs) == type(of: rhs) else { return .notEqual } + // Shortcut: For `Equatable` objects, compare them directly, + // no need to create a mirror and enumerate the properties. + + if isEquatableValue(lhs) { + return .with(isEqual(lhs, rhs)) + } + let lhs = Mirror(reflecting: lhs) - // 2) Values with no fields are always Equal. + // Values with no fields are technically always equal, but + // we mark it with a special value for recursing through value trees. guard lhs.children.isEmpty == false else { - return .equal + return .hasNoFields } let rhs = Mirror(reflecting: rhs) - // 3) Enumerate each property, by enumerating the `Mirrors`. + // Enumerate each property by enumerating the value's `Mirror`. var hadEquatableProperty = false for (prop1, prop2) in zip(lhs.children, rhs.children) { + + if isEquatableValue(prop1.value) { + + // If a property is `Equatable`, we can directly check it here. - // 3a) Skip any values which are not themselves `Equatable`. - - guard isEquatableValue(prop1.value) else { - continue - } - - hadEquatableProperty = true - - // 3b) Finally, compare the underlying values. - - guard isEqual(prop1.value, prop2.value) else { - return .notEqual + hadEquatableProperty = true + + // Compare the underlying `Equatable` value. + + guard isEqual(prop1.value, prop2.value) else { + return .notEqual + } + } else { + + // Othewise, we will recursively check its child values. + + let result = areEquatablePropertiesEqual(prop1.value, prop2.value) + + switch result { + case .equal: + hadEquatableProperty ||= true + + case .notEqual: + hadEquatableProperty ||= true + return .notEqual + + case .hasNoFields: + hadEquatableProperty ||= false + + case .error: + hadEquatableProperty ||= false + } } } if hadEquatableProperty { - // 4a) All `Equatable` properties were equal, so we're equal. + // We made it through the entire list of properties, and found at least + // one `Equatable` property, so we are equal. return .equal } else { - // 4b) We found no `Equatable` properties – behavior is undefined. + // We found no `Equatable` properties – behavior is undefined. return .error(.noEquatableProperties) } } @@ -81,10 +110,16 @@ public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> AreEquatabl @_spi(ListableInternal) public enum AreEquatablePropertiesEqualResult : Equatable { + case equal case notEqual + case hasNoFields case error(Error) + public static func with(_ value: Bool) -> Self { + value ?.equal : .notEqual + } + public enum Error { case noEquatableProperties } @@ -151,3 +186,14 @@ extension Wrapped: AnyEquatable where Value: Equatable { private protocol AnyEquatable { static func isEqual(lhs: Any, rhs: Any) -> Bool } + + + +infix operator ||= + +extension Bool { + + fileprivate static func ||= (lhs : inout Bool, rhs : Bool) { + lhs = lhs || rhs + } +} diff --git a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift index 75d9a4d6f..a3f6f4d13 100644 --- a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift +++ b/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift @@ -111,6 +111,8 @@ public extension IsEquivalentContent where Self:Equatable @_spi(ListableInternal) +/// Our default implementation compares the `Equatable` properties of the +/// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. public func defaultIsEquivalentImplementation(_ lhs : Value, _ rhs : Value) -> Bool { let result = areEquatablePropertiesEqual(lhs, rhs) @@ -121,6 +123,9 @@ public func defaultIsEquivalentImplementation(_ lhs : Value, _ rhs : Valu case .notEqual: return false + case .hasNoFields: + return true + case .error(let error): switch error { diff --git a/ListableUI/Tests/ComparePropertiesTests.swift b/ListableUI/Tests/ComparePropertiesTests.swift index 156a20cd0..2c1fefdf2 100644 --- a/ListableUI/Tests/ComparePropertiesTests.swift +++ b/ListableUI/Tests/ComparePropertiesTests.swift @@ -13,6 +13,8 @@ class ComparePropertiesTests : XCTestCase { func test_compare() { + // Check values which aren't Equatable but have Equatable properties. + XCTAssertEqual( .equal, areEquatablePropertiesEqual( @@ -73,6 +75,8 @@ class ComparePropertiesTests : XCTestCase { ) ) + // Ensure we message that there were no Equatable properties to compare. + XCTAssertEqual( .error(.noEquatableProperties), areEquatablePropertiesEqual( @@ -80,6 +84,40 @@ class ComparePropertiesTests : XCTestCase { TestValueWithNoEquatableProperties() ) ) + + // Check that we properly handle values which themselves are Equatable. + + XCTAssertEqual( + .equal, + areEquatablePropertiesEqual( + EquatableValue( + title: "A Title", + detail: "Some Detail", + count: 10 + ), + EquatableValue( + title: "A Title", + detail: "Some Detail", + count: 10 + ) + ) + ) + + XCTAssertEqual( + .notEqual, + areEquatablePropertiesEqual( + EquatableValue( + title: "A Title", + detail: "Some Detail", + count: 10 + ), + EquatableValue( + title: "Another Title", + detail: "Some Detail", + count: 10 + ) + ) + ) } func test_performance() { @@ -122,6 +160,14 @@ fileprivate struct TestValue { } +fileprivate struct EquatableValue : Equatable { + + var title : String + var detail : String? + var count : Int +} + + fileprivate struct NonEquatableValue { var value : Any From 977846973599d77a4f3f8d567b134dc92ad65b77 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 Jul 2022 15:13:58 -0700 Subject: [PATCH 20/38] Rename IsEquivalentContent, add some more tests. --- .../Sources/Element+HeaderFooter.swift | 10 +- BlueprintUILists/Sources/Element+Item.swift | 6 +- .../Sources/ListableBuilder+Element.swift | 8 +- .../Sources/Section+Element.swift | 4 +- .../Tests/ListableBuilder+ElementTests.swift | 2 +- CHANGELOG.md | 4 +- .../Sources/HeaderFooter/HeaderFooter.swift | 2 +- .../HeaderFooter/HeaderFooterContent.swift | 2 +- ...ntent.swift => EquivalentComparable.swift} | 12 +- ListableUI/Sources/Item/ItemContent.swift | 16 +- ListableUI/Tests/ListableBuilderTests.swift | 180 ++++++++++++++++++ 11 files changed, 216 insertions(+), 30 deletions(-) rename ListableUI/Sources/IsEquivalent/{IsEquivalentContent.swift => EquivalentComparable.swift} (93%) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 50fbedf78..b2c676757 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -50,8 +50,8 @@ extension Element where Self:Equatable { } -/// Ensures that the `IsEquivalentContent` initializer for `WrappedHeaderFooterContent` is called. -extension Element where Self:IsEquivalentContent { +/// Ensures that the `EquivalentComparable` initializer for `WrappedHeaderFooterContent` is called. +extension Element where Self:EquivalentComparable { public func listHeaderFooter( configure : (inout HeaderFooter>) -> () = { _ in } @@ -86,7 +86,7 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF } } - init(represented : ElementType) where ElementType:IsEquivalentContent { + init(represented : ElementType) where ElementType:EquivalentComparable { self.represented = represented self.isEquivalent = { @@ -101,5 +101,9 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF public var elementRepresentation: Element { represented } + + public var reappliesToVisibleView: ReappliesToVisibleView { + .ifNotEquivalent + } } diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index 1228b433c..01b2a8b16 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -58,8 +58,8 @@ extension Element where Self:Equatable { } -/// Ensures that the `IsEquivalentContent` initializer for `WrappedElementContent` is called. -extension Element where Self:IsEquivalentContent { +/// Ensures that the `EquivalentComparable` initializer for `WrappedElementContent` is called. +extension Element where Self:EquivalentComparable { public func listItem( id : AnyHashable? = nil, @@ -111,7 +111,7 @@ public struct WrappedElementContent : BlueprintItemContent init( identifierValue: AnyHashable?, represented: ElementType - ) where ElementType:IsEquivalentContent { + ) where ElementType:EquivalentComparable { self.represented = represented self.identifierValue = identifierValue diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index 698dd9bf6..d3b0a8be2 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -35,8 +35,8 @@ public extension ListableArrayBuilder where ContentType == AnyItemConvertible { [element.listItem()] } - /// Ensures that the `IsEquivalentContent`version of `.listItem()` is called. - static func buildExpression(_ element: ElementType) -> Component where ElementType:IsEquivalentContent { + /// Ensures that the `EquivalentComparable`version of `.listItem()` is called. + static func buildExpression(_ element: ElementType) -> Component where ElementType:EquivalentComparable { [element.listItem()] } @@ -57,8 +57,8 @@ public extension ListableValueBuilder where ContentType == AnyHeaderFooterConver return element.listHeaderFooter() } - /// Ensures that the `IsEquivalentContent`version of `.listHeaderFooter()` is called. - static func buildBlock(_ element: ElementType) -> ContentType where ElementType:IsEquivalentContent { + /// Ensures that the `EquivalentComparable`version of `.listHeaderFooter()` is called. + static func buildBlock(_ element: ElementType) -> ContentType where ElementType:EquivalentComparable { return element.listHeaderFooter() } diff --git a/BlueprintUILists/Sources/Section+Element.swift b/BlueprintUILists/Sources/Section+Element.swift index 038a4c827..f0cf94732 100644 --- a/BlueprintUILists/Sources/Section+Element.swift +++ b/BlueprintUILists/Sources/Section+Element.swift @@ -29,7 +29,7 @@ extension Section { self.items.append(element.listItem()) } - public mutating func add(_ element : ElementType) where ElementType:IsEquivalentContent + public mutating func add(_ element : ElementType) where ElementType:EquivalentComparable { self.items.append(element.listItem()) } @@ -52,7 +52,7 @@ extension Section { lhs.add(rhs) } - public static func += (lhs : inout Section, rhs : ElementType) where ElementType:IsEquivalentContent + public static func += (lhs : inout Section, rhs : ElementType) where ElementType:EquivalentComparable { lhs.add(rhs) } diff --git a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift index a30908044..33b599118 100644 --- a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift +++ b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift @@ -215,7 +215,7 @@ fileprivate struct EquatableElement : ProxyElement, Equatable { } -fileprivate struct EquivalentElement : ProxyElement, IsEquivalentContent { +fileprivate struct EquivalentElement : ProxyElement, EquivalentComparable { var calledIsEquivalent : () -> () diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f16b9108..7591211fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,10 +27,12 @@ ### Changed -- Definition of `isEquivalent(to:)` has been moved to `IsEquivalentContent`. +- Definition of `isEquivalent(to:)` has been moved to `EquivalentComparable`. - The `ListableBuilder` result builder is now `ListableArrayBuilder`. +- In many cases, you no longer need to implement `isEquivalent(to:)`. Listable will traverse the `Equatable` properties on your contents to determine when content should be re-measured. To improve performance for long lists or complex content objects, you're still encouraged to either make those content objects conform to `Equatable`, or implement `isEquivalent(to:)`. + ### Misc # Past Releases diff --git a/ListableUI/Sources/HeaderFooter/HeaderFooter.swift b/ListableUI/Sources/HeaderFooter/HeaderFooter.swift index d9cb7eef5..110cbe3b0 100644 --- a/ListableUI/Sources/HeaderFooter/HeaderFooter.swift +++ b/ListableUI/Sources/HeaderFooter/HeaderFooter.swift @@ -12,7 +12,7 @@ public typealias Header = HeaderFooter public typealias Footer = HeaderFooter -public struct HeaderFooter : AnyHeaderFooter, AnyHeaderFooterConvertible +public struct HeaderFooter : AnyHeaderFooter { public var content : Content diff --git a/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift b/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift index 61e256aae..718af00e8 100644 --- a/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift +++ b/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift @@ -44,7 +44,7 @@ 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 : IsEquivalentContent, AnyHeaderFooterConvertible +public protocol HeaderFooterContent : EquivalentComparable, AnyHeaderFooterConvertible { // // MARK: Default Properties diff --git a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift b/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift similarity index 93% rename from ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift rename to ListableUI/Sources/IsEquivalent/EquivalentComparable.swift index a3f6f4d13..7b2848d44 100644 --- a/ListableUI/Sources/IsEquivalent/IsEquivalentContent.swift +++ b/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift @@ -1,5 +1,5 @@ // -// IsEquivalentContent.swift +// EquivalentComparable.swift // ListableUI // // Created by Kyle Van Essen on 11/28/21. @@ -12,16 +12,16 @@ import Foundation /// remeasure the content and re-layout the list. /// /// ## Note -/// You should rarely need to implement ``IsEquivalentContent/isEquivalent(to:)-15tcq`` +/// You should rarely need to implement ``EquivalentComparable/isEquivalent(to:)-15tcq`` /// yourself. By default, Listable will... /// - For regular objects, compare all `Equatable` properties on your object to see if they changed. /// - For `Equatable` objects, check to see if the object is equal. /// /// If you do need to implement this method yourself (eg, your object has no equatable properties, -/// or cannot conform to `Equatable`, see ``IsEquivalentContent/isEquivalent(to:)-15tcq`` +/// or cannot conform to `Equatable`, see ``EquivalentComparable/isEquivalent(to:)-15tcq`` /// for a full discussion of correct (and incorrect) implementations. /// -public protocol IsEquivalentContent { +public protocol EquivalentComparable { /// /// Used by the list to determine when the content of content has changed; in order to @@ -90,7 +90,7 @@ public protocol IsEquivalentContent { } -public extension IsEquivalentContent +public extension EquivalentComparable { /// Our default implementation compares the `Equatable` properties of the /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. @@ -101,7 +101,7 @@ public extension IsEquivalentContent } -public extension IsEquivalentContent where Self:Equatable +public extension EquivalentComparable where Self:Equatable { /// If your content is `Equatable`, `isEquivalent` is based on the `Equatable` implementation. func isEquivalent(to other : Self) -> Bool { diff --git a/ListableUI/Sources/Item/ItemContent.swift b/ListableUI/Sources/Item/ItemContent.swift index 3b5923165..6ef291913 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 : IsEquivalentContent, AnyItemConvertible where Coordinator.ItemContentType == Self +public protocol ItemContent : EquivalentComparable, AnyItemConvertible where Coordinator.ItemContentType == Self { // // MARK: Identification @@ -475,13 +475,13 @@ public extension ItemContent { /// Provides a default implementation of `identifierValue` when self conforms to Swift's `Identifiable` protocol. -@available(iOS 13.0, *) -public extension ItemContent where Self:Identifiable -{ - var identifierValue : ID { - self.id - } -} +//@available(iOS 13.0, *) +//public extension ItemContent where Self:Identifiable +//{ +// var identifierValue : ID { +// self.id +// } +//} /// Implement `wasMoved` in terms of `isEquivalent(to:)` by default. diff --git a/ListableUI/Tests/ListableBuilderTests.swift b/ListableUI/Tests/ListableBuilderTests.swift index ced3443aa..dcc6d5a10 100644 --- a/ListableUI/Tests/ListableBuilderTests.swift +++ b/ListableUI/Tests/ListableBuilderTests.swift @@ -158,6 +158,96 @@ class ListableBuilderTests : XCTestCase { ) } + 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) + } + } + + 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) + } + fileprivate func build( @ListableArrayBuilder using builder : () -> [Content] ) -> [Content] @@ -166,3 +256,93 @@ class ListableBuilderTests : XCTestCase { } } + +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, EquivalentComparable { + + 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, EquivalentComparable { + + 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) {} +} + From 0260289a47e79e7ea0b86836673336713471d69b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 Jul 2022 15:50:06 -0700 Subject: [PATCH 21/38] Peformance: Remove some casting checks --- .../IsEquivalent/CompareProperties.swift | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/ListableUI/Sources/IsEquivalent/CompareProperties.swift b/ListableUI/Sources/IsEquivalent/CompareProperties.swift index df1fb567e..cb50793bd 100644 --- a/ListableUI/Sources/IsEquivalent/CompareProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareProperties.swift @@ -63,15 +63,13 @@ public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> AreEquatabl for (prop1, prop2) in zip(lhs.children, rhs.children) { - if isEquatableValue(prop1.value) { + if let result = isEqualIfEquatable(prop1.value, prop2.value) { // If a property is `Equatable`, we can directly check it here. hadEquatableProperty = true - // Compare the underlying `Equatable` value. - - guard isEqual(prop1.value, prop2.value) else { + if result == false { return .notEqual } } else { @@ -167,6 +165,21 @@ private func isEquatableValue(_ value: Any) -> Bool { } +/// Checks if the provided `lhs` and `rhs` values are equal if they are `Equatable`. +private func isEqualIfEquatable(_ lhs: Any, _ rhs : Any) -> Bool? { + + func check(value: Value) -> Bool? { + if let typeInfo = Wrapped.self as? AnyEquatable.Type { + return typeInfo.isEqual(lhs: lhs, rhs: rhs) + } else { + return nil + } + } + + return _openExistential(lhs, do: check) +} + + fileprivate enum Wrapped {} @@ -174,6 +187,9 @@ extension Wrapped: AnyEquatable where Value: Equatable { fileprivate static func isEqual(lhs: Any, rhs: Any) -> Bool { + /// TODO: Can we make this an as! because I think at this point we're + /// guaranteed to be correctly `Value`? I think? Is that faster? + guard let lhs = lhs as? Value, let rhs = rhs as? Value else { return false } From d166aa6942700b40e0f2997e0a053291bb6e4235 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 Jul 2022 18:56:53 -0700 Subject: [PATCH 22/38] Found a Mirror bug, add a test for it --- .../IsEquivalent/CompareProperties.swift | 3 - ListableUI/Tests/ComparePropertiesTests.swift | 156 ++++++++++++++++++ 2 files changed, 156 insertions(+), 3 deletions(-) diff --git a/ListableUI/Sources/IsEquivalent/CompareProperties.swift b/ListableUI/Sources/IsEquivalent/CompareProperties.swift index cb50793bd..9237f6198 100644 --- a/ListableUI/Sources/IsEquivalent/CompareProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareProperties.swift @@ -187,9 +187,6 @@ extension Wrapped: AnyEquatable where Value: Equatable { fileprivate static func isEqual(lhs: Any, rhs: Any) -> Bool { - /// TODO: Can we make this an as! because I think at this point we're - /// guaranteed to be correctly `Value`? I think? Is that faster? - guard let lhs = lhs as? Value, let rhs = rhs as? Value else { return false } diff --git a/ListableUI/Tests/ComparePropertiesTests.swift b/ListableUI/Tests/ComparePropertiesTests.swift index 2c1fefdf2..5af05f247 100644 --- a/ListableUI/Tests/ComparePropertiesTests.swift +++ b/ListableUI/Tests/ComparePropertiesTests.swift @@ -13,6 +13,76 @@ class ComparePropertiesTests : XCTestCase { func test_compare() { + // Check String behavior. + + XCTAssertEqual( + .equal, + areEquatablePropertiesEqual( + "A String", + "A String" + ) + ) + + XCTAssertEqual( + .notEqual, + areEquatablePropertiesEqual( + "A String", + "A String!" + ) + ) + + XCTAssertEqual( + .equal, + areEquatablePropertiesEqual( + "A String" as Any, + "A String" as Any + ) + ) + + XCTAssertEqual( + .notEqual, + areEquatablePropertiesEqual( + "A String" as Any, + "A String!" as Any + ) + ) + + // Check Int behavior. + + XCTAssertTrue(_isPOD(Int.self)) + + XCTAssertEqual( + .equal, + areEquatablePropertiesEqual( + 10, + 10 + ) + ) + + XCTAssertEqual( + .notEqual, + areEquatablePropertiesEqual( + 10, + 11 + ) + ) + + XCTAssertEqual( + .equal, + areEquatablePropertiesEqual( + 10 as Any, + 10 as Any + ) + ) + + XCTAssertEqual( + .notEqual, + areEquatablePropertiesEqual( + 10 as Any, + 11 as Any + ) + ) + // Check values which aren't Equatable but have Equatable properties. XCTAssertEqual( @@ -85,6 +155,78 @@ class ComparePropertiesTests : XCTestCase { ) ) + // Check what happens with a non-Equatable enum, but it has associated types which may be Equatable. + + XCTAssertEqual( + .error(.noEquatableProperties), + areEquatablePropertiesEqual( + NonEquatableEnumOnlyValue(enumValue: .one), + NonEquatableEnumOnlyValue(enumValue: .one) + ) + ) + + XCTAssertEqual( + .error(.noEquatableProperties), + areEquatablePropertiesEqual( + NonEquatableEnumOnlyValue(enumValue: .one), + NonEquatableEnumOnlyValue(enumValue: .two) + ) + ) + + XCTAssertEqual( + .equal, + areEquatablePropertiesEqual( + NonEquatableEnumOnlyValue(enumValue: .three("Hello")), + NonEquatableEnumOnlyValue(enumValue: .three("Hello")) + ) + ) + + XCTAssertEqual( + .notEqual, + areEquatablePropertiesEqual( + NonEquatableEnumOnlyValue(enumValue: .three("Hello")), + NonEquatableEnumOnlyValue(enumValue: .three("Hello!!")) + ) + ) + + // !! Swift (?) bug: Swift cannot resolve these two strings as the same type. + // Seems to be a bug in the `Mirror` type passthrough for enums with associated values, or something. + // For now, it'll fail open; eg not finding any Equatable values, but that is wrong. + + XCTAssertEqual( + .error(.noEquatableProperties), + areEquatablePropertiesEqual( + NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))), + NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))) + ) + ) + + XCTAssertEqual( + .error(.noEquatableProperties), // Should be `.equal`. + areEquatablePropertiesEqual( + NonEquatableEnumOnlyValue(enumValue: .five("Some String")), + NonEquatableEnumOnlyValue(enumValue: .five("Some String")) + ) + ) + + // END: Swift Bug + + XCTAssertEqual( + .error(.noEquatableProperties), // Should be `.equal`. + areEquatablePropertiesEqual( + NonEquatableEnumOnlyValue(enumValue: .five(1)), + NonEquatableEnumOnlyValue(enumValue: .five(1)) + ) + ) + + XCTAssertEqual( + .notEqual, + areEquatablePropertiesEqual( + NonEquatableEnumOnlyValue(enumValue: .five("Some String")), + NonEquatableEnumOnlyValue(enumValue: .five(1)) + ) + ) + // Check that we properly handle values which themselves are Equatable. XCTAssertEqual( @@ -180,3 +322,17 @@ fileprivate struct TestValueWithNoEquatableProperties { var closure2 : () -> () = {} } + + +fileprivate struct NonEquatableEnumOnlyValue { + + var enumValue : AnEnum + + enum AnEnum { + case one + case two + case three(String) + case four(NonEquatableValue) + case five(Any) + } +} From 6467a3e1974993c4994fa7cf832163ba2b7cd937 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 Jul 2022 20:24:10 -0700 Subject: [PATCH 23/38] Add Swift 5.7 support --- .../IsEquivalent/CompareProperties.swift | 37 +++++++++++++++++++ ListableUI/Tests/ComparePropertiesTests.swift | 37 +++++++++++++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/ListableUI/Sources/IsEquivalent/CompareProperties.swift b/ListableUI/Sources/IsEquivalent/CompareProperties.swift index 9237f6198..708d02b65 100644 --- a/ListableUI/Sources/IsEquivalent/CompareProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareProperties.swift @@ -127,6 +127,13 @@ public enum AreEquatablePropertiesEqualResult : Equatable { /// Checks if the two provided values are the same type and Equatable. private func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { +#if swift(>=5.7) + if let lhs = lhs as? any Equatable { + return lhs.isEqual(to: rhs) + } else { + return false + } +#else func check(value: Value) -> Bool { if let typeInfo = Wrapped.self as? AnyEquatable.Type { return typeInfo.isEqual(lhs: lhs, rhs: rhs) @@ -151,23 +158,35 @@ private func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { /// https://github.com/apple/swift/blob/main/stdlib/public/core/Builtin.swift#L1005 return _openExistential(lhs, do: check) +#endif } /// Checks if the provided `value` is `Equatable`. private func isEquatableValue(_ value: Any) -> Bool { +#if swift(>=5.7) + return value is any Equatable +#else func check(value: Value) -> Bool { Wrapped.self is AnyEquatable.Type } return _openExistential(value, do: check) +#endif } /// Checks if the provided `lhs` and `rhs` values are equal if they are `Equatable`. private func isEqualIfEquatable(_ lhs: Any, _ rhs : Any) -> Bool? { +#if swift(>=5.7) + if let lhs = lhs as? any Equatable { + return lhs.isEqual(to: rhs) + } else { + return nil + } +#else func check(value: Value) -> Bool? { if let typeInfo = Wrapped.self as? AnyEquatable.Type { return typeInfo.isEqual(lhs: lhs, rhs: rhs) @@ -177,8 +196,25 @@ private func isEqualIfEquatable(_ lhs: Any, _ rhs : Any) -> Bool? { } return _openExistential(lhs, do: check) +#endif +} + + + +#if swift(>=5.7) + +extension Equatable { + + fileprivate func isEqual(to other: Any) -> Bool { + guard let other = other as? Self else { + return false + } + + return self == other + } } +#else fileprivate enum Wrapped {} @@ -200,6 +236,7 @@ private protocol AnyEquatable { static func isEqual(lhs: Any, rhs: Any) -> Bool } +#endif infix operator ||= diff --git a/ListableUI/Tests/ComparePropertiesTests.swift b/ListableUI/Tests/ComparePropertiesTests.swift index 5af05f247..beb2feeb1 100644 --- a/ListableUI/Tests/ComparePropertiesTests.swift +++ b/ListableUI/Tests/ComparePropertiesTests.swift @@ -189,10 +189,20 @@ class ComparePropertiesTests : XCTestCase { ) ) - // !! Swift (?) bug: Swift cannot resolve these two strings as the same type. + // !! Swift 5.6 and earlier bug: Swift cannot resolve these two strings as the same type. // Seems to be a bug in the `Mirror` type passthrough for enums with associated values, or something. // For now, it'll fail open; eg not finding any Equatable values, but that is wrong. + // This is resolved in Swift 5.7 when we can use `any Equatable`. +#if swift(>=5.7) + XCTAssertEqual( + .equal, + areEquatablePropertiesEqual( + NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))), + NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))) + ) + ) +#else XCTAssertEqual( .error(.noEquatableProperties), areEquatablePropertiesEqual( @@ -200,7 +210,17 @@ class ComparePropertiesTests : XCTestCase { NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))) ) ) +#endif +#if swift(>=5.7) + XCTAssertEqual( + .equal, + areEquatablePropertiesEqual( + NonEquatableEnumOnlyValue(enumValue: .five("Some String")), + NonEquatableEnumOnlyValue(enumValue: .five("Some String")) + ) + ) +#else XCTAssertEqual( .error(.noEquatableProperties), // Should be `.equal`. areEquatablePropertiesEqual( @@ -208,9 +228,17 @@ class ComparePropertiesTests : XCTestCase { NonEquatableEnumOnlyValue(enumValue: .five("Some String")) ) ) +#endif - // END: Swift Bug - +#if swift(>=5.7) + XCTAssertEqual( + .equal, + areEquatablePropertiesEqual( + NonEquatableEnumOnlyValue(enumValue: .five(1)), + NonEquatableEnumOnlyValue(enumValue: .five(1)) + ) + ) +#else XCTAssertEqual( .error(.noEquatableProperties), // Should be `.equal`. areEquatablePropertiesEqual( @@ -218,6 +246,9 @@ class ComparePropertiesTests : XCTestCase { NonEquatableEnumOnlyValue(enumValue: .five(1)) ) ) +#endif + + // END: Swift Bug XCTAssertEqual( .notEqual, From bd6cc744d2bbcfee003fd896e3681c83f8ccdc38 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 29 Jul 2022 20:51:14 -0700 Subject: [PATCH 24/38] Improve error messages --- BlueprintUILists/Sources/ListableBuilder+Element.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index d3b0a8be2..ab27be933 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -40,6 +40,7 @@ public extension ListableArrayBuilder where ContentType == AnyItemConvertible { [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() } @@ -62,6 +63,7 @@ public extension ListableValueBuilder where ContentType == AnyHeaderFooterConver 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() } From 01675e40b8daa6e2af57f093e276696b3bb0ee43 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 7 Aug 2022 20:18:42 -0700 Subject: [PATCH 25/38] Address self review --- .../Sources/ListableBuilder+Element.swift | 5 - .../Tests/Element+ItemTests.swift | 17 -- .../IsEquivalent/CompareProperties.swift | 260 +++++++++++++++--- .../IsEquivalent/EquivalentComparable.swift | 12 +- ListableUI/Sources/Item/ItemContent.swift | 14 +- ListableUI/Tests/ComparePropertiesTests.swift | 242 +++++++++++++--- 6 files changed, 440 insertions(+), 110 deletions(-) delete mode 100644 BlueprintUILists/Tests/Element+ItemTests.swift diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index ab27be933..4b57878a7 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -19,11 +19,6 @@ import ListableUI /// Element2() // An Element /// } /// ``` -/// -/// ## Note -/// Takes advantage of `@_disfavoredOverload` to avoid ambiguous method resolution with the default implementations. -/// See more here: https://github.com/apple/swift/blob/main/docs/ReferenceGuides/UnderscoredAttributes.md#_disfavoredoverload -/// public extension ListableArrayBuilder where ContentType == AnyItemConvertible { static func buildExpression(_ element: ElementType) -> Component { diff --git a/BlueprintUILists/Tests/Element+ItemTests.swift b/BlueprintUILists/Tests/Element+ItemTests.swift deleted file mode 100644 index 67a23a73b..000000000 --- a/BlueprintUILists/Tests/Element+ItemTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// Element+ItemTests.swift -// BlueprintUILists-Unit-Tests -// -// Created by Kyle Van Essen on 7/27/22. -// - -import XCTest -import BlueprintUILists - - -class Element_ItemTests : XCTestCase { - - -} - - diff --git a/ListableUI/Sources/IsEquivalent/CompareProperties.swift b/ListableUI/Sources/IsEquivalent/CompareProperties.swift index 708d02b65..1a41be6f9 100644 --- a/ListableUI/Sources/IsEquivalent/CompareProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareProperties.swift @@ -8,15 +8,16 @@ import Foundation -/// Checks if the `Equatable` properies on two objects are equal, even if the object itself is not `Equatable`. +/// Checks if the `Equatable` properies on two values are equal, even if the value itself +/// is not `Equatable`. If the value is `Equatable`, its `Equatable` implementation is invoked. /// /// ## Example /// For the following struct, the `title`, `detail` and `count` properties will be compared. The -/// `closure` will be ignored, and the properties of `nonEquatable` will be traversed to look -/// for `Equatable` sub-properties (and so on). +/// `closure` will be ignored (since it's not `Equatable)`, and the properties of `nonEquatable` +/// will be recursively traversed to look for `Equatable` sub-properties (and so on). /// /// ``` -/// fileprivate struct MyStruct { +/// struct MyStruct { /// /// var title : String /// var detail : String? @@ -28,44 +29,84 @@ import Foundation /// } /// ``` /// -/// Inspired by https://github.com/objcio/S01E264-comparing-views/blob/master/Sources/NotSwiftUIState/AnyEquatable.swift +/// ## Note +/// This method is used to power the default ``EquivalentComparable/isEquivalent(to:)-7meyq`` implementation. +/// +/// ## Thank You / Credit +/// Inspired by the folks at objc.io, and in particular thanks to @chriseidhof! +/// +/// https://talk.objc.io/episodes/S01E264-comparing-views +/// https://twitter.com/chriseidhof/status/1552612392789499905 +/// https://github.com/objcio/S01E264-comparing-views/blob/master/Sources/NotSwiftUIState/IsEquatableType.swift /// @_spi(ListableInternal) -public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> AreEquatablePropertiesEqualResult { +public func compareEquatableProperties(_ lhs : Any, _ rhs : Any) -> CompareEquatablePropertiesResult { - // We can't compare values unless the objects are the same type. + /// We can't compare values unless they are the same type. guard type(of: lhs) == type(of: rhs) else { return .notEqual } - // Shortcut: For `Equatable` objects, compare them directly, - // no need to create a mirror and enumerate the properties. + /// Base case: For `Equatable` objects, compare them directly, + /// no need to create a mirror and enumerate the properties. - if isEquatableValue(lhs) { - return .with(isEqual(lhs, rhs)) + if let isEqual = isEqualIfEquatable(lhs, rhs) { + return .with(isEqual) } - let lhs = Mirror(reflecting: lhs) + /// Base case: For collections, we need to handle them differently, because + /// some collections like `Set` or `Dictionary` will not + /// return `Mirror.children` in a stable order. + + if let isEqual = compareContentsIfSameTypeCollections(lhs, rhs) { + return .with(isEqual) + } - // Values with no fields are technically always equal, but - // we mark it with a special value for recursing through value trees. + let lhsMirror = Mirror(reflecting: lhs) - guard lhs.children.isEmpty == false else { + guard lhsMirror.children.isEmpty == false else { + + /// Values with no fields are technically always equal, but + /// we mark it with a special value for recursing through value trees + /// and eventually returning an error. + return .hasNoFields } - let rhs = Mirror(reflecting: rhs) + let rhsMirror = Mirror(reflecting: rhs) + + /// Values with different child counts are not equal. This can happen + /// if the value type is providing its own mirror via `CustomReflectable`, + /// which Swift collections (like Array, Set, Dictionary) do. - // Enumerate each property by enumerating the value's `Mirror`. + guard lhsMirror.children.count == rhsMirror.children.count else { + return .notEqual + } + + /// Enumerate each property by enumerating the value's `Mirror`. var hadEquatableProperty = false - for (prop1, prop2) in zip(lhs.children, rhs.children) { + for (prop1, prop2) in zip(lhsMirror.children, rhsMirror.children) { + /// 1) Check if the property is directly `Equatable` itself. + /// 2) If it's a `Collection`, we'll check the contents. + /// 3) If neither of those are true, recursively check children. + if let result = isEqualIfEquatable(prop1.value, prop2.value) { - // If a property is `Equatable`, we can directly check it here. + /// If a property is `Equatable`, we can directly check it here. + + hadEquatableProperty = true + + if result == false { + return .notEqual + } + } else if let result = compareContentsIfSameTypeCollections(prop1.value, prop2.value) { + + /// If the properties were both collections, + /// and are the same type, they may be Equal. hadEquatableProperty = true @@ -74,9 +115,9 @@ public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> AreEquatabl } } else { - // Othewise, we will recursively check its child values. + /// Othewise, we will recursively check its child values. - let result = areEquatablePropertiesEqual(prop1.value, prop2.value) + let result = compareEquatableProperties(prop1.value, prop2.value) switch result { case .equal: @@ -95,19 +136,33 @@ public func areEquatablePropertiesEqual(_ lhs : Any, _ rhs : Any) -> AreEquatabl } } + let hasChildren = lhsMirror.children.count > 0 + if hadEquatableProperty { - // We made it through the entire list of properties, and found at least - // one `Equatable` property, so we are equal. + /// We made it through the entire list of properties, and found at least + /// one `Equatable` property, so we are equal. return .equal } else { - // We found no `Equatable` properties – behavior is undefined. - return .error(.noEquatableProperties) + if hasChildren { + + /// We found no `Equatable` properties, but we did + /// have _some_ children, which our display state is likely + /// derived from (eg, closures or something). Report an error + /// and make the consumer implement `isEquivalent(to:)` themselves. + + return .error(.noEquatableProperties) + } else { + + /// We had no children at all, so we're equal. + + return .equal + } } } @_spi(ListableInternal) -public enum AreEquatablePropertiesEqualResult : Equatable { +public enum CompareEquatablePropertiesResult : Equatable { case equal case notEqual @@ -135,7 +190,7 @@ private func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { } #else func check(value: Value) -> Bool { - if let typeInfo = Wrapped.self as? AnyEquatable.Type { + if let typeInfo = Wrapped.self as? IsEquatableType.Type { return typeInfo.isEqual(lhs: lhs, rhs: rhs) } else { return false @@ -146,7 +201,7 @@ private func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { /// Swift will take the `Any` type (the existential type), and call the provided `body` /// with the existential converted to the contained type. Because we have no constraint /// on the contained type (just a `Value` generic), we can then check if the contained type - /// will conform to `AnyEquatable`. + /// will conform to `IsEquatableType`. /// /// ``` /// public func _openExistential( @@ -169,7 +224,22 @@ private func isEquatableValue(_ value: Any) -> Bool { return value is any Equatable #else func check(value: Value) -> Bool { - Wrapped.self is AnyEquatable.Type + Wrapped.self is IsEquatableType.Type + } + + return _openExistential(value, do: check) +#endif +} + + +/// Checks if the provided `value` is a `Collection` +private func isACollection(_ value: Any) -> Bool { + +#if swift(>=5.7) + return value is any Collection +#else + func check(value: Value) -> Bool { + Wrapped.self is IsCollectionType.Type } return _openExistential(value, do: check) @@ -188,7 +258,7 @@ private func isEqualIfEquatable(_ lhs: Any, _ rhs : Any) -> Bool? { } #else func check(value: Value) -> Bool? { - if let typeInfo = Wrapped.self as? AnyEquatable.Type { + if let typeInfo = Wrapped.self as? IsEquatableType.Type { return typeInfo.isEqual(lhs: lhs, rhs: rhs) } else { return nil @@ -200,6 +270,23 @@ private func isEqualIfEquatable(_ lhs: Any, _ rhs : Any) -> Bool? { } +/// Checks if the provided `lhs` and `rhs` values are equal if they both the same type of `Collection`. +private func compareContentsIfSameTypeCollections(_ lhs: Any, _ rhs : Any) -> Bool? { + + func check(value: Value) -> Bool? { + if let typeInfo = Wrapped.self as? IsCollectionType.Type { + return typeInfo.compareContents(lhs: lhs, rhs: rhs) + } else { + return nil + } + } + + return _openExistential(lhs, do: check) +} + + +fileprivate enum Wrapped {} + #if swift(>=5.7) @@ -216,10 +303,13 @@ extension Equatable { #else -fileprivate enum Wrapped {} + +private protocol IsEquatableType { + static func isEqual(lhs: Any, rhs: Any) -> Bool +} -extension Wrapped: AnyEquatable where Value: Equatable { +extension Wrapped: IsEquatableType where Value: Equatable { fileprivate static func isEqual(lhs: Any, rhs: Any) -> Bool { @@ -231,19 +321,115 @@ extension Wrapped: AnyEquatable where Value: Equatable { } } +#endif -private protocol AnyEquatable { - static func isEqual(lhs: Any, rhs: Any) -> Bool +private protocol IsCollectionType { + + static func compareContents(lhs : Any, rhs : Any) -> Bool } -#endif + +extension Wrapped: IsCollectionType where Value: Collection { + + static func compareContents(lhs : Any, rhs : Any) -> Bool { + + guard let lhs = lhs as? ErasedComparableCollection else { + return false + } + + return lhs.compareContents(to: rhs) + } +} + +private protocol ErasedComparableCollection { + + func compareContents(to other : Any) -> Bool + +} + + +extension Dictionary : ErasedComparableCollection { + + fileprivate func compareContents(to other : Any) -> Bool { + + guard let other = other as? Self else { + return false + } + + guard count == other.count else { + return false + } + + for key in keys { + let lhs = self[key] + let rhs = other[key] + + guard let lhs = lhs, let rhs = rhs else { + return false + } + + if compareEquatableProperties(lhs, rhs) != .equal { + return false + } + } + + return true + } +} + + +extension Set : ErasedComparableCollection { + + fileprivate func compareContents(to other : Any) -> Bool { + + guard let other = other as? Self else { + return false + } + + guard count == other.count else { + return false + } + + for value in self { + if other.contains(value) == false { + return false + } + } + + return true + } +} + +extension Array : ErasedComparableCollection { + + fileprivate func compareContents(to other : Any) -> Bool { + + guard let other = other as? Self else { + return false + } + + guard count == other.count else { + return false + } + + for (index, lhs) in self.enumerated() { + let rhs = other[index] + + if compareEquatableProperties(lhs, rhs) != .equal { + return false + } + } + + return true + } +} infix operator ||= -extension Bool { +fileprivate extension Bool { - fileprivate static func ||= (lhs : inout Bool, rhs : Bool) { + static func ||= (lhs : inout Bool, rhs : Bool) { lhs = lhs || rhs } } diff --git a/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift b/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift index 7b2848d44..4bc0874c8 100644 --- a/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift +++ b/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift @@ -114,7 +114,7 @@ public extension EquivalentComparable where Self:Equatable /// Our default implementation compares the `Equatable` properties of the /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. public func defaultIsEquivalentImplementation(_ lhs : Value, _ rhs : Value) -> Bool { - let result = areEquatablePropertiesEqual(lhs, rhs) + let result = compareEquatableProperties(lhs, rhs) switch result { case .equal: @@ -133,15 +133,19 @@ public func defaultIsEquivalentImplementation(_ lhs : Value, _ rhs : Valu assertionFailure( """ FAILURE: The default `isEquivalent(to:)` implementation could not find any `Equatable` properties \ - on \(Value.self). In release versions, `isEquivalent(to:)` will always return false, which \ - will affect performance. You should implement `isEquivalent(to:)` and check the relevant \ - sub-properties to provide proper conformance: + on \(Value.self), but there were other properties. + + In release versions, `isEquivalent(to:)` will always return false, which will affect performance. + + You should implement `isEquivalent(to:)` and check the relevant sub-properties to provide proper conformance: ``` func isEquivalent(to other : Self) -> Bool { myVar.subProperty == other.myVar.subProperty && ... } ``` + + If your object can conform to `Equatable`, you can also add that conformance to provide `isEquivalent(to:)`. """ ) } diff --git a/ListableUI/Sources/Item/ItemContent.swift b/ListableUI/Sources/Item/ItemContent.swift index 6ef291913..2e711f67d 100644 --- a/ListableUI/Sources/Item/ItemContent.swift +++ b/ListableUI/Sources/Item/ItemContent.swift @@ -475,13 +475,13 @@ public extension ItemContent { /// Provides a default implementation of `identifierValue` when self conforms to Swift's `Identifiable` protocol. -//@available(iOS 13.0, *) -//public extension ItemContent where Self:Identifiable -//{ -// var identifierValue : ID { -// self.id -// } -//} +@available(iOS 13.0, *) +public extension ItemContent where Self:Identifiable +{ + var identifierValue : ID { + self.id + } +} /// Implement `wasMoved` in terms of `isEquivalent(to:)` by default. diff --git a/ListableUI/Tests/ComparePropertiesTests.swift b/ListableUI/Tests/ComparePropertiesTests.swift index beb2feeb1..a6c8a5cb8 100644 --- a/ListableUI/Tests/ComparePropertiesTests.swift +++ b/ListableUI/Tests/ComparePropertiesTests.swift @@ -11,13 +11,11 @@ import XCTest class ComparePropertiesTests : XCTestCase { - func test_compare() { - - // Check String behavior. + func test_compare_string() { XCTAssertEqual( .equal, - areEquatablePropertiesEqual( + compareEquatableProperties( "A String", "A String" ) @@ -25,7 +23,7 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .notEqual, - areEquatablePropertiesEqual( + compareEquatableProperties( "A String", "A String!" ) @@ -33,7 +31,7 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .equal, - areEquatablePropertiesEqual( + compareEquatableProperties( "A String" as Any, "A String" as Any ) @@ -41,19 +39,20 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .notEqual, - areEquatablePropertiesEqual( + compareEquatableProperties( "A String" as Any, "A String!" as Any ) ) + } - // Check Int behavior. + func test_compare_int() { XCTAssertTrue(_isPOD(Int.self)) XCTAssertEqual( .equal, - areEquatablePropertiesEqual( + compareEquatableProperties( 10, 10 ) @@ -61,7 +60,7 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .notEqual, - areEquatablePropertiesEqual( + compareEquatableProperties( 10, 11 ) @@ -69,7 +68,7 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .equal, - areEquatablePropertiesEqual( + compareEquatableProperties( 10 as Any, 10 as Any ) @@ -77,17 +76,31 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .notEqual, - areEquatablePropertiesEqual( + compareEquatableProperties( 10 as Any, 11 as Any ) ) - // Check values which aren't Equatable but have Equatable properties. + } + + func test_empty() { + + XCTAssertEqual( + .hasNoFields, + compareEquatableProperties( + EmptyValue(), + EmptyValue() + ) + ) + + } + + func test_compare_non_equatable_values() { XCTAssertEqual( .equal, - areEquatablePropertiesEqual( + compareEquatableProperties( TestValue( title: "A Title", detail: "Some Detail", @@ -107,7 +120,7 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .notEqual, - areEquatablePropertiesEqual( + compareEquatableProperties( TestValue( title: "A Different Title", detail: "Some Detail", @@ -127,7 +140,7 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .notEqual, - areEquatablePropertiesEqual( + compareEquatableProperties( TestValue( title: "A Title", detail: "Some Detail", @@ -149,7 +162,7 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .error(.noEquatableProperties), - areEquatablePropertiesEqual( + compareEquatableProperties( TestValueWithNoEquatableProperties(), TestValueWithNoEquatableProperties() ) @@ -159,7 +172,7 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .error(.noEquatableProperties), - areEquatablePropertiesEqual( + compareEquatableProperties( NonEquatableEnumOnlyValue(enumValue: .one), NonEquatableEnumOnlyValue(enumValue: .one) ) @@ -167,7 +180,7 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .error(.noEquatableProperties), - areEquatablePropertiesEqual( + compareEquatableProperties( NonEquatableEnumOnlyValue(enumValue: .one), NonEquatableEnumOnlyValue(enumValue: .two) ) @@ -175,7 +188,7 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .equal, - areEquatablePropertiesEqual( + compareEquatableProperties( NonEquatableEnumOnlyValue(enumValue: .three("Hello")), NonEquatableEnumOnlyValue(enumValue: .three("Hello")) ) @@ -183,21 +196,22 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .notEqual, - areEquatablePropertiesEqual( + compareEquatableProperties( NonEquatableEnumOnlyValue(enumValue: .three("Hello")), NonEquatableEnumOnlyValue(enumValue: .three("Hello!!")) ) ) - // !! Swift 5.6 and earlier bug: Swift cannot resolve these two strings as the same type. - // Seems to be a bug in the `Mirror` type passthrough for enums with associated values, or something. - // For now, it'll fail open; eg not finding any Equatable values, but that is wrong. - // This is resolved in Swift 5.7 when we can use `any Equatable`. + // ⚠️ Swift 5.6 and earlier bug: Swift cannot resolve these two strings as an `Equatable` type. + // This seems to be a bug in how `Any` instances can get cast back to a strict type + // through `_openExistential`. This is resolved in Swift 5.7 / Xcode 14. + // + // Once we support Xcode 14 and later only, we can remove the `#else` branches here and in `ComparableProperties.swift`. #if swift(>=5.7) XCTAssertEqual( .equal, - areEquatablePropertiesEqual( + compareEquatableProperties( NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))), NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))) ) @@ -205,7 +219,7 @@ class ComparePropertiesTests : XCTestCase { #else XCTAssertEqual( .error(.noEquatableProperties), - areEquatablePropertiesEqual( + compareEquatableProperties( NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))), NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))) ) @@ -215,15 +229,15 @@ class ComparePropertiesTests : XCTestCase { #if swift(>=5.7) XCTAssertEqual( .equal, - areEquatablePropertiesEqual( + compareEquatableProperties( NonEquatableEnumOnlyValue(enumValue: .five("Some String")), NonEquatableEnumOnlyValue(enumValue: .five("Some String")) ) ) #else XCTAssertEqual( - .error(.noEquatableProperties), // Should be `.equal`. - areEquatablePropertiesEqual( + .error(.noEquatableProperties), + compareEquatableProperties( NonEquatableEnumOnlyValue(enumValue: .five("Some String")), NonEquatableEnumOnlyValue(enumValue: .five("Some String")) ) @@ -233,36 +247,36 @@ class ComparePropertiesTests : XCTestCase { #if swift(>=5.7) XCTAssertEqual( .equal, - areEquatablePropertiesEqual( + compareEquatableProperties( NonEquatableEnumOnlyValue(enumValue: .five(1)), NonEquatableEnumOnlyValue(enumValue: .five(1)) ) ) #else XCTAssertEqual( - .error(.noEquatableProperties), // Should be `.equal`. - areEquatablePropertiesEqual( + .error(.noEquatableProperties), + compareEquatableProperties( NonEquatableEnumOnlyValue(enumValue: .five(1)), NonEquatableEnumOnlyValue(enumValue: .five(1)) ) ) #endif - // END: Swift Bug - XCTAssertEqual( .notEqual, - areEquatablePropertiesEqual( + compareEquatableProperties( NonEquatableEnumOnlyValue(enumValue: .five("Some String")), NonEquatableEnumOnlyValue(enumValue: .five(1)) ) ) - // Check that we properly handle values which themselves are Equatable. + } + + func test_compare_equatable_values() { XCTAssertEqual( .equal, - areEquatablePropertiesEqual( + compareEquatableProperties( EquatableValue( title: "A Title", detail: "Some Detail", @@ -278,7 +292,7 @@ class ComparePropertiesTests : XCTestCase { XCTAssertEqual( .notEqual, - areEquatablePropertiesEqual( + compareEquatableProperties( EquatableValue( title: "A Title", detail: "Some Detail", @@ -293,9 +307,154 @@ class ComparePropertiesTests : XCTestCase { ) } + func test_array() { + + XCTAssertEqual( + .equal, + compareEquatableProperties( + [], + [] + ) + ) + + XCTAssertEqual( + .equal, + compareEquatableProperties( + ["A", "B"], + ["A", "B"] + ) + ) + + XCTAssertEqual( + .equal, + compareEquatableProperties( + ["A", "B"], + ["A", "B"] + ) + ) + + XCTAssertEqual( + .notEqual, + compareEquatableProperties( + ["A", "B"], + ["A", "B", "C"] + ) + ) + + XCTAssertEqual( + .notEqual, + compareEquatableProperties( + ["A", "2"], + ["A", 2] + ) + ) + } + + func test_set() { + + XCTAssertEqual( + .equal, + compareEquatableProperties( + Set(), + Set() + ) + ) + + XCTAssertEqual( + .equal, + compareEquatableProperties( + Set(["A", "B"]), + Set(["A", "B"]) + ) + ) + + XCTAssertEqual( + .equal, + compareEquatableProperties( + Set(["A", "B"]), + Set(["B", "A"]) + ) + ) + + XCTAssertEqual( + .notEqual, + compareEquatableProperties( + Set(["A", "B"]), + Set(["A", "B", "C"]) + ) + ) + + XCTAssertEqual( + .notEqual, + compareEquatableProperties( + Set(["A", "2"]), + Set(["A", 2]) + ) + ) + } + + func test_dictionary() { + + XCTAssertEqual( + .equal, + compareEquatableProperties( + Dictionary(), + Dictionary() + ) + ) + + XCTAssertEqual( + .equal, + compareEquatableProperties( + ["Key1": 1, "Key2" : 2] as Dictionary, + ["Key1": 1, "Key2" : 2] as Dictionary + ) + ) + + XCTAssertEqual( + .notEqual, + compareEquatableProperties( + ["Key1": 1, "Key2" : 3] as Dictionary, + ["Key1": 1, "Key3" : 2] as Dictionary + ) + ) + + XCTAssertEqual( + .notEqual, + compareEquatableProperties( + ["Key1": 1, "Key2" : 2] as Dictionary, + ["Key1": 1, "Key3" : 2] as Dictionary + ) + ) + + // ⚠️ Swift 5.6 and earlier bug: Swift cannot resolve these two strings as an `Equatable` type. + // This seems to be a bug in how `Any` instances can get cast back to a strict type + // through `_openExistential`. This is resolved in Swift 5.7 / Xcode 14. + // + // Once we support Xcode 14 and later only, we can remove the `#else` branches here and in `ComparableProperties.swift`. + +#if swift(>=5.7) + XCTAssertEqual( + .equal, + compareEquatableProperties( + ["Key1": 1, "Key2" : 2] as Dictionary, + ["Key1": 1, "Key2" : 2] as Dictionary + ) + ) +#else + XCTAssertEqual( + .notEqual, + compareEquatableProperties( + ["Key1": 1, "Key2" : 2] as Dictionary, + ["Key1": 1, "Key2" : 2] as Dictionary + ) + ) +#endif + } + func test_performance() { determineAverage(for: 1.0) { - _ = areEquatablePropertiesEqual( + _ = compareEquatableProperties( TestValue( title: "A Title", count: 10, @@ -355,6 +514,9 @@ fileprivate struct TestValueWithNoEquatableProperties { } +fileprivate struct EmptyValue {} + + fileprivate struct NonEquatableEnumOnlyValue { var enumValue : AnEnum From 2c9da83b841bc9d8ac025a6b8f86d9c297fbbab9 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 7 Aug 2022 21:19:54 -0700 Subject: [PATCH 26/38] fix --- ListableUI/Sources/IsEquivalent/CompareProperties.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ListableUI/Sources/IsEquivalent/CompareProperties.swift b/ListableUI/Sources/IsEquivalent/CompareProperties.swift index 1a41be6f9..7c0c4327d 100644 --- a/ListableUI/Sources/IsEquivalent/CompareProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareProperties.swift @@ -135,14 +135,15 @@ public func compareEquatableProperties(_ lhs : Any, _ rhs : Any) -> CompareEquat } } } - - let hasChildren = lhsMirror.children.count > 0 - + if hadEquatableProperty { /// We made it through the entire list of properties, and found at least /// one `Equatable` property, so we are equal. return .equal } else { + + let hasChildren = lhsMirror.children.count > 0 + if hasChildren { /// We found no `Equatable` properties, but we did From 4294b9bf7b87fcf459c8dd7d637e95ec3d38df08 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 7 Aug 2022 22:18:16 -0700 Subject: [PATCH 27/38] Add more specific error --- .../IsEquivalent/CompareProperties.swift | 30 ++++++++++++------- .../IsEquivalent/EquivalentComparable.swift | 2 +- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/ListableUI/Sources/IsEquivalent/CompareProperties.swift b/ListableUI/Sources/IsEquivalent/CompareProperties.swift index 7c0c4327d..32e59519a 100644 --- a/ListableUI/Sources/IsEquivalent/CompareProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareProperties.swift @@ -59,8 +59,8 @@ public func compareEquatableProperties(_ lhs : Any, _ rhs : Any) -> CompareEquat /// some collections like `Set` or `Dictionary` will not /// return `Mirror.children` in a stable order. - if let isEqual = compareContentsIfSameTypeCollections(lhs, rhs) { - return .with(isEqual) + if let result = compareContentsIfSameTypeCollections(lhs, rhs) { + return .with(result == .equal) } let lhsMirror = Mirror(reflecting: lhs) @@ -110,7 +110,7 @@ public func compareEquatableProperties(_ lhs : Any, _ rhs : Any) -> CompareEquat hadEquatableProperty = true - if result == false { + if result != .equal { return .notEqual } } else { @@ -174,8 +174,18 @@ public enum CompareEquatablePropertiesResult : Equatable { value ?.equal : .notEqual } - public enum Error { + public enum Error : Equatable { case noEquatableProperties + case unknownCollectionType(UnknownType) + + public struct UnknownType : Equatable { + + var unknownType : Any.Type + + public static func == (lhs: Self, rhs: Self) -> Bool { + type(of: lhs.unknownType) == type(of: rhs.unknownType) + } + } } } @@ -272,9 +282,9 @@ private func isEqualIfEquatable(_ lhs: Any, _ rhs : Any) -> Bool? { /// Checks if the provided `lhs` and `rhs` values are equal if they both the same type of `Collection`. -private func compareContentsIfSameTypeCollections(_ lhs: Any, _ rhs : Any) -> Bool? { +private func compareContentsIfSameTypeCollections(_ lhs: Any, _ rhs : Any) -> CompareEquatablePropertiesResult? { - func check(value: Value) -> Bool? { + func check(value: Value) -> CompareEquatablePropertiesResult? { if let typeInfo = Wrapped.self as? IsCollectionType.Type { return typeInfo.compareContents(lhs: lhs, rhs: rhs) } else { @@ -326,19 +336,19 @@ extension Wrapped: IsEquatableType where Value: Equatable { private protocol IsCollectionType { - static func compareContents(lhs : Any, rhs : Any) -> Bool + static func compareContents(lhs : Any, rhs : Any) -> CompareEquatablePropertiesResult } extension Wrapped: IsCollectionType where Value: Collection { - static func compareContents(lhs : Any, rhs : Any) -> Bool { + static func compareContents(lhs : Any, rhs : Any) -> CompareEquatablePropertiesResult { guard let lhs = lhs as? ErasedComparableCollection else { - return false + return .error(.unknownCollectionType(.init(unknownType: type(of: lhs)))) } - return lhs.compareContents(to: rhs) + return .with(lhs.compareContents(to: rhs)) } } diff --git a/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift b/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift index 4bc0874c8..2bf9ac2d7 100644 --- a/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift +++ b/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift @@ -129,7 +129,7 @@ public func defaultIsEquivalentImplementation(_ lhs : Value, _ rhs : Valu case .error(let error): switch error { - case .noEquatableProperties: + case .noEquatableProperties, .unknownCollectionType: assertionFailure( """ FAILURE: The default `isEquivalent(to:)` implementation could not find any `Equatable` properties \ From 8fbfa3037f28c45e555197c4da393ee6679b3d22 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 8 Aug 2022 16:30:31 -0700 Subject: [PATCH 28/38] Update how we handle backgrounds for elements --- .../Sources/Element+HeaderFooter.swift | 55 ++++++++++++++++++- BlueprintUILists/Sources/Element+Item.swift | 54 +++++++++++++++++- .../IsEquivalent/CompareProperties.swift | 2 +- 3 files changed, 106 insertions(+), 5 deletions(-) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index b2c676757..cb02f10f1 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -18,13 +18,22 @@ extension Element { /// Converts the given `Element` into a Listable `HeaderFooter`. You many also optionally /// configure the header / footer, setting its values such as the `onTap` callbacks, etc. /// + /// You may also provide a background and pressed background as well as tap actions. + /// /// ```swift /// MyElement(...) /// .listHeaderFooter { header in /// header.onTap = { ... } /// } + /// .background { + /// Box(backgroundColor: ...).inset(...) + /// } + /// .onTap { + /// // Handle the tap event. + /// } show: { + /// Box(backgroundColor: ...).inset(...) + /// } /// ``` - /// public func listHeaderFooter( configure : (inout HeaderFooter>) -> () = { _ in } ) -> HeaderFooter> { @@ -36,6 +45,29 @@ extension Element { } +extension HeaderFooter where Content : _AnyWrappedHeaderFooterContent { + + /// TODO + public func background(_ provider : @escaping () -> Element?) -> Self { + var copy = self + copy.content._backgroundProvider = provider + return copy + } + + /// TODO + public func onTap( + _ onTap : @escaping () -> (), + show background : @escaping () -> Element + ) -> Self { + var copy = self + copy.onTap = onTap + copy.content._backgroundProvider = background + return copy + } +} + + + /// Ensures that the `Equatable` initializer for `WrappedHeaderFooterContent` is called. extension Element where Self:Equatable { @@ -64,7 +96,7 @@ extension Element where Self:EquivalentComparable { } -public struct WrappedHeaderFooterContent : BlueprintHeaderFooterContent +public struct WrappedHeaderFooterContent : BlueprintHeaderFooterContent, _AnyWrappedHeaderFooterContent { public let represented : ElementType @@ -102,8 +134,27 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF represented } + public var _backgroundProvider : () -> Element? = { nil } + + public var background: Element? { + _backgroundProvider() + } + + public var _pressedBackgroundProvider : () -> Element? = { nil } + + public var pressedBackground: Element? { + _pressedBackgroundProvider() + } + public var reappliesToVisibleView: ReappliesToVisibleView { .ifNotEquivalent } } + +public protocol _AnyWrappedHeaderFooterContent { + + var _backgroundProvider : () -> Element? { get set } + var _pressedBackgroundProvider : () -> Element? { get set } + +} diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index 01b2a8b16..acda33c7d 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -16,15 +16,23 @@ extension Element { /// Converts the given `Element` into a Listable `Item` with the provided ID. You can use this ID /// to scroll to or later access the item through the regular list access APIs. + /// /// You many also optionally configure the item, setting its values such as the `onDisplay` callbacks, etc. /// + /// You can also provide a background or selected background via the `background` and `selectedBackground` modifiers. + /// /// ```swift /// MyElement(...) /// .listItem(id: "my-provided-id") { item in /// item.insertAndRemoveAnimations = .scaleUp /// } + /// .background { + /// Box(backgroundColor: ...).inset(...) + /// } + /// .selectedBackground(.tappable) { + /// Box(backgroundColor: ...).inset(...) + /// } /// ``` - /// public func listItem( id : AnyHashable? = nil, configure : (inout Item>) -> () = { _ in } @@ -40,6 +48,28 @@ extension Element { } +extension Item where Content : _AnyWrappedElementContent { + + /// TODO + public func background(_ provider : @escaping (ApplyItemContentInfo) -> Element?) -> Self { + var copy = self + copy.content._backgroundProvider = provider + return copy + } + + /// TODO + public func selectedBackground( + _ selectionStyle : ItemSelectionStyle, + selectedBackground : @escaping (ApplyItemContentInfo) -> Element? + ) -> Self { + var copy = self + copy.selectionStyle = selectionStyle + copy.content._backgroundProvider = selectedBackground + return copy + } +} + + /// Ensures that the `Equatable` initializer for `WrappedElementContent` is called. extension Element where Self:Equatable { @@ -76,7 +106,7 @@ extension Element where Self:EquivalentComparable { } -public struct WrappedElementContent : BlueprintItemContent +public struct WrappedElementContent : BlueprintItemContent, _AnyWrappedElementContent { public let identifierValue: AnyHashable? @@ -128,7 +158,27 @@ public struct WrappedElementContent : BlueprintItemContent represented } + public var _backgroundProvider: (ApplyItemContentInfo) -> Element? = { _ in nil } + + public func backgroundElement(with info: ApplyItemContentInfo) -> Element? { + _backgroundProvider(info) + } + + public var _selectedBackgroundProvider: (ApplyItemContentInfo) -> Element? = { _ in nil } + + public func selectedBackgroundElement(with info: ApplyItemContentInfo) -> Element? { + _selectedBackgroundProvider(info) + } + public var reappliesToVisibleView: ReappliesToVisibleView { .ifNotEquivalent } } + + +public protocol _AnyWrappedElementContent { + + var _backgroundProvider : (ApplyItemContentInfo) -> Element? { get set } + var _selectedBackgroundProvider : (ApplyItemContentInfo) -> Element? { get set } + +} diff --git a/ListableUI/Sources/IsEquivalent/CompareProperties.swift b/ListableUI/Sources/IsEquivalent/CompareProperties.swift index 32e59519a..4409aa1d8 100644 --- a/ListableUI/Sources/IsEquivalent/CompareProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareProperties.swift @@ -171,7 +171,7 @@ public enum CompareEquatablePropertiesResult : Equatable { case error(Error) public static func with(_ value: Bool) -> Self { - value ?.equal : .notEqual + value ? .equal : .notEqual } public enum Error : Equatable { From 9ac38400c064517cf66925150919c8f0081e6f81 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 11 Aug 2022 16:11:51 -0700 Subject: [PATCH 29/38] Update tests to compare equatability performance --- .../Sources/Element+HeaderFooter.swift | 13 ++---- ListableUI/Tests/ComparePropertiesTests.swift | 46 ++++++++++++++++++- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index cb02f10f1..90935107e 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -28,9 +28,7 @@ extension Element { /// .background { /// Box(backgroundColor: ...).inset(...) /// } - /// .onTap { - /// // Handle the tap event. - /// } show: { + /// .pressedBackground { /// Box(backgroundColor: ...).inset(...) /// } /// ``` @@ -55,13 +53,12 @@ extension HeaderFooter where Content : _AnyWrappedHeaderFooterContent { } /// TODO - public func onTap( - _ onTap : @escaping () -> (), - show background : @escaping () -> Element + public func pressedBackground( + _ background : @escaping () -> Element, + onTap : @escaping () -> () ) -> Self { var copy = self - copy.onTap = onTap - copy.content._backgroundProvider = background + copy.content._pressedBackgroundProvider = background return copy } } diff --git a/ListableUI/Tests/ComparePropertiesTests.swift b/ListableUI/Tests/ComparePropertiesTests.swift index a6c8a5cb8..ead165321 100644 --- a/ListableUI/Tests/ComparePropertiesTests.swift +++ b/ListableUI/Tests/ComparePropertiesTests.swift @@ -453,7 +453,10 @@ class ComparePropertiesTests : XCTestCase { } func test_performance() { - determineAverage(for: 1.0) { + + print("Compare based on properties...") + + determineAverage(for: 0.5) { _ = compareEquatableProperties( TestValue( title: "A Title", @@ -469,6 +472,23 @@ class ComparePropertiesTests : XCTestCase { ) ) } + + print("Compare based on synthesized Equatable implementation...") + + determineAverage(for: 0.5) { + _ = compareEquatableProperties( + TestValue.ButEquatable( + title: "A Title", + count: 10, + enumValue: .foo + ), + TestValue.ButEquatable( + title: "A Title", + count: 10, + enumValue: .foo + ) + ) + } } } @@ -489,6 +509,30 @@ fileprivate struct TestValue { case foo case bar(String) } + + /// Mirrors the structure of the parent type but with a synthesized `Equatable` + /// implementation to compare performance. + + fileprivate struct ButEquatable : Equatable { + + var title : String + var detail : String? + var count : Int + + var enumValue : EnumValue + + var nonEquatable: EquatableValue = .init(value: "An inner string") + + enum EnumValue : Equatable { + case foo + case bar(String) + } + + fileprivate struct EquatableValue : Equatable { + + var value : AnyHashable + } + } } From d65a3d408d83b8243637a5deca2f49aab340a28c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 1 Feb 2023 16:03:35 -0800 Subject: [PATCH 30/38] Code review --- .../Sources/Element+HeaderFooter.swift | 98 ++++++++------- BlueprintUILists/Sources/Element+Item.swift | 114 ++++++++++-------- Demo/Demo.xcodeproj/project.pbxproj | 2 +- .../IsEquivalent/CompareProperties.swift | 4 +- ListableUI/Tests/ComparePropertiesTests.swift | 41 ++++++- 5 files changed, 158 insertions(+), 101 deletions(-) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 90935107e..0ad735291 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -24,55 +24,44 @@ extension Element { /// MyElement(...) /// .listHeaderFooter { header in /// header.onTap = { ... } - /// } - /// .background { + /// } background: { /// Box(backgroundColor: ...).inset(...) - /// } - /// .pressedBackground { + /// } pressedBackground: { /// Box(backgroundColor: ...).inset(...) /// } /// ``` public func listHeaderFooter( + background : @escaping () -> Element? = { nil }, + pressedBackground : @escaping () -> Element? = { nil }, configure : (inout HeaderFooter>) -> () = { _ in } ) -> HeaderFooter> { HeaderFooter( - WrappedHeaderFooterContent(represented: self), + WrappedHeaderFooterContent( + represented: self, + background: background, + pressedBackground: pressedBackground + ), configure: configure ) } } -extension HeaderFooter where Content : _AnyWrappedHeaderFooterContent { - - /// TODO - public func background(_ provider : @escaping () -> Element?) -> Self { - var copy = self - copy.content._backgroundProvider = provider - return copy - } - - /// TODO - public func pressedBackground( - _ background : @escaping () -> Element, - onTap : @escaping () -> () - ) -> Self { - var copy = self - copy.content._pressedBackgroundProvider = background - return copy - } -} - - /// 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), + WrappedHeaderFooterContent( + represented: self, + background: background, + pressedBackground: pressedBackground + ), configure: configure ) } @@ -83,41 +72,70 @@ extension Element where Self:Equatable { extension Element where Self:EquivalentComparable { public func listHeaderFooter( + background : @escaping () -> Element? = { nil }, + pressedBackground : @escaping () -> Element? = { nil }, configure : (inout HeaderFooter>) -> () = { _ in } ) -> HeaderFooter> { HeaderFooter( - WrappedHeaderFooterContent(represented: self), + WrappedHeaderFooterContent( + represented: self, + background: background, + pressedBackground: pressedBackground + ), configure: configure ) } } -public struct WrappedHeaderFooterContent : BlueprintHeaderFooterContent, _AnyWrappedHeaderFooterContent +public struct WrappedHeaderFooterContent : BlueprintHeaderFooterContent { public let represented : ElementType private let isEquivalent : (Self, Self) -> Bool - init(represented : ElementType) { + init( + represented : ElementType, + background : @escaping () -> Element?, + pressedBackground : @escaping () -> Element? + ) { self.represented = represented + self.backgroundProvider = background + self.pressedBackgroundProvider = pressedBackground + self.isEquivalent = { defaultIsEquivalentImplementation($0.represented, $1.represented) } } - init(represented : ElementType) where ElementType:Equatable { + 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) where ElementType:EquivalentComparable { + init( + represented : ElementType, + background : @escaping () -> Element?, + pressedBackground : @escaping () -> Element? + ) where ElementType:EquivalentComparable + { self.represented = represented + self.backgroundProvider = background + self.pressedBackgroundProvider = pressedBackground + self.isEquivalent = { $0.represented.isEquivalent(to: $1.represented) } @@ -131,27 +149,19 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF represented } - public var _backgroundProvider : () -> Element? = { nil } + var backgroundProvider : () -> Element? = { nil } public var background: Element? { - _backgroundProvider() + backgroundProvider() } - public var _pressedBackgroundProvider : () -> Element? = { nil } + var pressedBackgroundProvider : () -> Element? = { nil } public var pressedBackground: Element? { - _pressedBackgroundProvider() + pressedBackgroundProvider() } public var reappliesToVisibleView: ReappliesToVisibleView { .ifNotEquivalent } } - - -public protocol _AnyWrappedHeaderFooterContent { - - var _backgroundProvider : () -> Element? { get set } - var _pressedBackgroundProvider : () -> Element? { get set } - -} diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index acda33c7d..d2e927e97 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -23,66 +23,60 @@ extension Element { /// /// ```swift /// MyElement(...) - /// .listItem(id: "my-provided-id") { item in + /// .listItem(id: "my-provided-id", selection: .tappable) { item in /// item.insertAndRemoveAnimations = .scaleUp - /// } - /// .background { + /// } background: { _ in /// Box(backgroundColor: ...).inset(...) - /// } - /// .selectedBackground(.tappable) { + /// } selectedBackground: { _ in /// Box(backgroundColor: ...).inset(...) /// } /// ``` 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 + represented: self, + background: background, + selectedBackground: selectedBackground ), - configure: configure + configure: { + $0.selectionStyle = selection + + configure(&$0) + } ) } } -extension Item where Content : _AnyWrappedElementContent { - - /// TODO - public func background(_ provider : @escaping (ApplyItemContentInfo) -> Element?) -> Self { - var copy = self - copy.content._backgroundProvider = provider - return copy - } - - /// TODO - public func selectedBackground( - _ selectionStyle : ItemSelectionStyle, - selectedBackground : @escaping (ApplyItemContentInfo) -> Element? - ) -> Self { - var copy = self - copy.selectionStyle = selectionStyle - copy.content._backgroundProvider = selectedBackground - return copy - } -} - - /// 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 + represented: self, + background: background, + selectedBackground: selectedBackground ), - configure: configure + configure: { + $0.selectionStyle = selection + + configure(&$0) + } ) } } @@ -93,20 +87,29 @@ extension Element where Self:EquivalentComparable { 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 + represented: self, + background: background, + selectedBackground: selectedBackground ), - configure: configure + configure: { + $0.selectionStyle = selection + + configure(&$0) + } ) } } -public struct WrappedElementContent : BlueprintItemContent, _AnyWrappedElementContent +public struct WrappedElementContent : BlueprintItemContent { public let identifierValue: AnyHashable? @@ -116,11 +119,16 @@ public struct WrappedElementContent : BlueprintItemContent, init( identifierValue: AnyHashable?, - represented: ElementType + represented: ElementType, + background : @escaping (ApplyItemContentInfo) -> Element?, + selectedBackground : @escaping (ApplyItemContentInfo) -> Element? ) { self.represented = represented self.identifierValue = identifierValue + self.backgroundProvider = background + self.selectedBackgroundProvider = selectedBackground + self.isEquivalent = { defaultIsEquivalentImplementation($0.represented, $1.represented) } @@ -128,11 +136,17 @@ public struct WrappedElementContent : BlueprintItemContent, init( identifierValue: AnyHashable?, - represented: ElementType - ) where ElementType:Equatable { + 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 } @@ -140,11 +154,17 @@ public struct WrappedElementContent : BlueprintItemContent, init( identifierValue: AnyHashable?, - represented: ElementType - ) where ElementType:EquivalentComparable { + represented: ElementType, + background : @escaping (ApplyItemContentInfo) -> Element?, + selectedBackground : @escaping (ApplyItemContentInfo) -> Element? + ) where ElementType:EquivalentComparable + { self.represented = represented self.identifierValue = identifierValue + self.backgroundProvider = background + self.selectedBackgroundProvider = selectedBackground + self.isEquivalent = { $0.represented.isEquivalent(to: $1.represented) } @@ -158,27 +178,19 @@ public struct WrappedElementContent : BlueprintItemContent, represented } - public var _backgroundProvider: (ApplyItemContentInfo) -> Element? = { _ in nil } + var backgroundProvider: (ApplyItemContentInfo) -> Element? public func backgroundElement(with info: ApplyItemContentInfo) -> Element? { - _backgroundProvider(info) + backgroundProvider(info) } - public var _selectedBackgroundProvider: (ApplyItemContentInfo) -> Element? = { _ in nil } + var selectedBackgroundProvider: (ApplyItemContentInfo) -> Element? public func selectedBackgroundElement(with info: ApplyItemContentInfo) -> Element? { - _selectedBackgroundProvider(info) + selectedBackgroundProvider(info) } public var reappliesToVisibleView: ReappliesToVisibleView { .ifNotEquivalent } } - - -public protocol _AnyWrappedElementContent { - - var _backgroundProvider : (ApplyItemContentInfo) -> Element? { get set } - var _selectedBackgroundProvider : (ApplyItemContentInfo) -> Element? { get set } - -} diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 918c8312d..21056d932 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -585,7 +585,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; + GCC_OPTIMIZATION_LEVEL = s; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", diff --git a/ListableUI/Sources/IsEquivalent/CompareProperties.swift b/ListableUI/Sources/IsEquivalent/CompareProperties.swift index 4409aa1d8..358a1e287 100644 --- a/ListableUI/Sources/IsEquivalent/CompareProperties.swift +++ b/ListableUI/Sources/IsEquivalent/CompareProperties.swift @@ -440,7 +440,7 @@ infix operator ||= fileprivate extension Bool { - static func ||= (lhs : inout Bool, rhs : Bool) { - lhs = lhs || rhs + static func ||= (lhs : inout Bool, rhs : @autoclosure () -> Bool) { + lhs = lhs || rhs() } } diff --git a/ListableUI/Tests/ComparePropertiesTests.swift b/ListableUI/Tests/ComparePropertiesTests.swift index ead165321..e336aaf1a 100644 --- a/ListableUI/Tests/ComparePropertiesTests.swift +++ b/ListableUI/Tests/ComparePropertiesTests.swift @@ -456,7 +456,7 @@ class ComparePropertiesTests : XCTestCase { print("Compare based on properties...") - determineAverage(for: 0.5) { + determineAverage(for: 1.0) { _ = compareEquatableProperties( TestValue( title: "A Title", @@ -473,9 +473,26 @@ class ComparePropertiesTests : XCTestCase { ) } - print("Compare based on synthesized Equatable implementation...") + print("Compare based on properties, removing enum...") - determineAverage(for: 0.5) { + determineAverage(for: 1.0) { + _ = compareEquatableProperties( + TestValue.ButNoEnums( + title: "A Title", + count: 10, + closure: {} + ), + TestValue.ButNoEnums( + title: "A Title", + count: 10, + closure: {} + ) + ) + } + + print("Compare based on synthesized Equatable implementation...") + + determineAverage(for: 1.0) { _ = compareEquatableProperties( TestValue.ButEquatable( title: "A Title", @@ -510,6 +527,24 @@ fileprivate struct TestValue { case bar(String) } + /// Removing enums + + fileprivate struct ButNoEnums { + + var title : String + var detail : String? + var count : Int + + var nonEquatable: EquatableValue = .init(value: "An inner string") + + var closure : () -> () + + fileprivate struct EquatableValue : Equatable { + + var value : AnyHashable + } + } + /// Mirrors the structure of the parent type but with a synthesized `Equatable` /// implementation to compare performance. From 964a924563171b44660d4cf48566c9f6328b8684 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 25 May 2023 15:57:51 -0700 Subject: [PATCH 31/38] Follow up: Remove automatic equality, replace with easier method to define equivalency --- .../Sources/Element+HeaderFooter.swift | 58 +- BlueprintUILists/Sources/Element+Item.swift | 65 +- .../Sources/ListableBuilder+Element.swift | 18 +- .../Sources/Section+Element.swift | 33 +- .../Tests/ListableBuilder+ElementTests.swift | 6 +- CHANGELOG.md | 2 +- .../HeaderFooter/HeaderFooterContent.swift | 2 +- .../IsEquivalent/CompareProperties.swift | 446 ------------- ...omparable.swift => LayoutEquivalent.swift} | 75 +-- .../LayoutKeyPathEquivalent.swift | 155 +++++ ListableUI/Sources/Item/ItemContent.swift | 2 +- ListableUI/Tests/ComparePropertiesTests.swift | 610 ------------------ .../Tests/LayoutKeyPathEquivalent.swift | 53 ++ ListableUI/Tests/ListableBuilderTests.swift | 4 +- 14 files changed, 256 insertions(+), 1273 deletions(-) delete mode 100644 ListableUI/Sources/IsEquivalent/CompareProperties.swift rename ListableUI/Sources/IsEquivalent/{EquivalentComparable.swift => LayoutEquivalent.swift} (54%) create mode 100644 ListableUI/Sources/IsEquivalent/LayoutKeyPathEquivalent.swift delete mode 100644 ListableUI/Tests/ComparePropertiesTests.swift create mode 100644 ListableUI/Tests/LayoutKeyPathEquivalent.swift diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 0ad735291..b91676a77 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -6,48 +6,12 @@ // import BlueprintUI -@_spi(ListableInternal) import ListableUI // MARK: HeaderFooter / HeaderFooterContent Extensions -extension Element { - - /// Converts the given `Element` into a Listable `HeaderFooter`. You many also optionally - /// configure the header / footer, setting its values such as the `onTap` callbacks, etc. - /// - /// You may also provide a background and pressed background as well as tap actions. - /// - /// ```swift - /// MyElement(...) - /// .listHeaderFooter { header in - /// header.onTap = { ... } - /// } background: { - /// Box(backgroundColor: ...).inset(...) - /// } pressedBackground: { - /// Box(backgroundColor: ...).inset(...) - /// } - /// ``` - 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 `Equatable` initializer for `WrappedHeaderFooterContent` is called. extension Element where Self:Equatable { @@ -68,9 +32,10 @@ extension Element where Self:Equatable { } -/// Ensures that the `EquivalentComparable` initializer for `WrappedHeaderFooterContent` is called. -extension Element where Self:EquivalentComparable { +/// 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 }, @@ -94,21 +59,6 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF private let isEquivalent : (Self, Self) -> Bool - init( - represented : ElementType, - background : @escaping () -> Element?, - pressedBackground : @escaping () -> Element? - ) { - self.represented = represented - - self.backgroundProvider = background - self.pressedBackgroundProvider = pressedBackground - - self.isEquivalent = { - defaultIsEquivalentImplementation($0.represented, $1.represented) - } - } - init( represented : ElementType, background : @escaping () -> Element?, @@ -129,7 +79,7 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF represented : ElementType, background : @escaping () -> Element?, pressedBackground : @escaping () -> Element? - ) where ElementType:EquivalentComparable + ) where ElementType:LayoutEquivalent { self.represented = represented diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index d2e927e97..df495a4ad 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -6,53 +6,11 @@ // import BlueprintUI -@_spi(ListableInternal) import ListableUI // MARK: Item / ItemContent Extensions -extension Element { - - /// Converts the given `Element` into a Listable `Item` with the provided ID. You can use this ID - /// to scroll to or later access the item through the regular list access APIs. - /// - /// You many also optionally configure the item, setting its values such as the `onDisplay` callbacks, etc. - /// - /// You can also provide a background or selected background via the `background` and `selectedBackground` modifiers. - /// - /// ```swift - /// MyElement(...) - /// .listItem(id: "my-provided-id", selection: .tappable) { item in - /// item.insertAndRemoveAnimations = .scaleUp - /// } background: { _ in - /// Box(backgroundColor: ...).inset(...) - /// } selectedBackground: { _ in - /// Box(backgroundColor: ...).inset(...) - /// } - /// ``` - 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 `Equatable` initializer for `WrappedElementContent` is called. @@ -82,8 +40,8 @@ extension Element where Self:Equatable { } -/// Ensures that the `EquivalentComparable` initializer for `WrappedElementContent` is called. -extension Element where Self:EquivalentComparable { +/// Ensures that the `LayoutEquivalent` initializer for `WrappedElementContent` is called. +extension Element where Self:LayoutEquivalent { public func listItem( id : AnyHashable? = nil, @@ -117,23 +75,6 @@ public struct WrappedElementContent : BlueprintItemContent private let isEquivalent : (Self, Self) -> Bool - init( - identifierValue: AnyHashable?, - represented: ElementType, - background : @escaping (ApplyItemContentInfo) -> Element?, - selectedBackground : @escaping (ApplyItemContentInfo) -> Element? - ) { - self.represented = represented - self.identifierValue = identifierValue - - self.backgroundProvider = background - self.selectedBackgroundProvider = selectedBackground - - self.isEquivalent = { - defaultIsEquivalentImplementation($0.represented, $1.represented) - } - } - init( identifierValue: AnyHashable?, represented: ElementType, @@ -157,7 +98,7 @@ public struct WrappedElementContent : BlueprintItemContent represented: ElementType, background : @escaping (ApplyItemContentInfo) -> Element?, selectedBackground : @escaping (ApplyItemContentInfo) -> Element? - ) where ElementType:EquivalentComparable + ) where ElementType:LayoutEquivalent { self.represented = represented self.identifierValue = identifierValue diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index 4b57878a7..a3a87fc61 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -21,17 +21,14 @@ import ListableUI /// ``` public extension ListableArrayBuilder where ContentType == AnyItemConvertible { - static func buildExpression(_ element: ElementType) -> Component { - [element.listItem()] - } - /// Ensures that the `Equatable`version of `.listItem()` is called. static func buildExpression(_ element: ElementType) -> Component where ElementType:Equatable { [element.listItem()] } - /// Ensures that the `EquivalentComparable`version of `.listItem()` is called. - static func buildExpression(_ element: ElementType) -> Component where ElementType:EquivalentComparable { + /// Ensures that the `LayoutEquivalent`version of `.listItem()` is called. + @_disfavoredOverload + static func buildExpression(_ element: ElementType) -> Component where ElementType:LayoutEquivalent { [element.listItem()] } @@ -43,18 +40,15 @@ public extension ListableArrayBuilder where ContentType == AnyItemConvertible { public extension ListableValueBuilder where ContentType == AnyHeaderFooterConvertible { - - static func buildBlock(_ element: ElementType) -> ContentType { - return element.listHeaderFooter() - } /// Ensures that the `Equatable`version of `.listHeaderFooter()` is called. static func buildBlock(_ element: ElementType) -> ContentType where ElementType:Equatable { return element.listHeaderFooter() } - /// Ensures that the `EquivalentComparable`version of `.listHeaderFooter()` is called. - static func buildBlock(_ element: ElementType) -> ContentType where ElementType:EquivalentComparable { + /// Ensures that the `LayoutEquivalent`version of `.listHeaderFooter()` is called. + @_disfavoredOverload + static func buildBlock(_ element: ElementType) -> ContentType where ElementType:LayoutEquivalent { return element.listHeaderFooter() } diff --git a/BlueprintUILists/Sources/Section+Element.swift b/BlueprintUILists/Sources/Section+Element.swift index f0cf94732..8dcc48bc5 100644 --- a/BlueprintUILists/Sources/Section+Element.swift +++ b/BlueprintUILists/Sources/Section+Element.swift @@ -19,21 +19,24 @@ extension Section { /// section.add(Element2()) /// } /// ``` - public mutating func add(_ element : ElementType) - { - self.items.append(element.listItem()) - } - public mutating func add(_ element : ElementType) where ElementType:Equatable { self.items.append(element.listItem()) } - public mutating func add(_ element : ElementType) where ElementType:EquivalentComparable + /// 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:LayoutEquivalent { self.items.append(element.listItem()) } - + /// Adds `Element` support when building a `Section`. /// /// ```swift @@ -42,17 +45,21 @@ extension Section { /// section += Element2() /// } /// ``` - public static func += (lhs : inout Section, rhs : ElementType) - { - lhs.add(rhs) - } - public static func += (lhs : inout Section, rhs : ElementType) where ElementType:Equatable { lhs.add(rhs) } - public static func += (lhs : inout Section, rhs : ElementType) where ElementType:EquivalentComparable + /// 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 index 33b599118..a4aa564a2 100644 --- a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift +++ b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift @@ -184,7 +184,7 @@ fileprivate struct NonConvertibleElement : ProxyElement, ListElementNonConvertib } -fileprivate struct Element1 : ProxyElement { +fileprivate struct Element1 : ProxyElement, Equatable, LayoutEquivalent { var elementRepresentation: Element { Empty() @@ -192,7 +192,7 @@ fileprivate struct Element1 : ProxyElement { } -fileprivate struct Element2 : ProxyElement { +fileprivate struct Element2 : ProxyElement, Equatable, LayoutEquivalent { var elementRepresentation: Element { Empty() @@ -215,7 +215,7 @@ fileprivate struct EquatableElement : ProxyElement, Equatable { } -fileprivate struct EquivalentElement : ProxyElement, EquivalentComparable { +fileprivate struct EquivalentElement : ProxyElement, LayoutEquivalent { var calledIsEquivalent : () -> () diff --git a/CHANGELOG.md b/CHANGELOG.md index 7591211fe..186899ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ ### Changed -- Definition of `isEquivalent(to:)` has been moved to `EquivalentComparable`. +- Definition of `isEquivalent(to:)` has been moved to `LayoutEquivalent`. - The `ListableBuilder` result builder is now `ListableArrayBuilder`. diff --git a/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift b/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift index 718af00e8..49586aafc 100644 --- a/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift +++ b/ListableUI/Sources/HeaderFooter/HeaderFooterContent.swift @@ -44,7 +44,7 @@ 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 : EquivalentComparable, AnyHeaderFooterConvertible +public protocol HeaderFooterContent : LayoutEquivalent, AnyHeaderFooterConvertible { // // MARK: Default Properties diff --git a/ListableUI/Sources/IsEquivalent/CompareProperties.swift b/ListableUI/Sources/IsEquivalent/CompareProperties.swift deleted file mode 100644 index 358a1e287..000000000 --- a/ListableUI/Sources/IsEquivalent/CompareProperties.swift +++ /dev/null @@ -1,446 +0,0 @@ -// -// CompareProperties.swift -// ListableUI -// -// Created by Kyle Van Essen on 7/28/22. -// - -import Foundation - - -/// Checks if the `Equatable` properies on two values are equal, even if the value itself -/// is not `Equatable`. If the value is `Equatable`, its `Equatable` implementation is invoked. -/// -/// ## Example -/// For the following struct, the `title`, `detail` and `count` properties will be compared. The -/// `closure` will be ignored (since it's not `Equatable)`, and the properties of `nonEquatable` -/// will be recursively traversed to look for `Equatable` sub-properties (and so on). -/// -/// ``` -/// struct MyStruct { -/// -/// var title : String -/// var detail : String? -/// var count : Int -/// -/// var nonEquatable: NonEquatableValue -/// -/// var closure : () -> () -/// } -/// ``` -/// -/// ## Note -/// This method is used to power the default ``EquivalentComparable/isEquivalent(to:)-7meyq`` implementation. -/// -/// ## Thank You / Credit -/// Inspired by the folks at objc.io, and in particular thanks to @chriseidhof! -/// -/// https://talk.objc.io/episodes/S01E264-comparing-views -/// https://twitter.com/chriseidhof/status/1552612392789499905 -/// https://github.com/objcio/S01E264-comparing-views/blob/master/Sources/NotSwiftUIState/IsEquatableType.swift -/// -@_spi(ListableInternal) -public func compareEquatableProperties(_ lhs : Any, _ rhs : Any) -> CompareEquatablePropertiesResult { - - /// We can't compare values unless they are the same type. - - guard type(of: lhs) == type(of: rhs) else { - return .notEqual - } - - /// Base case: For `Equatable` objects, compare them directly, - /// no need to create a mirror and enumerate the properties. - - if let isEqual = isEqualIfEquatable(lhs, rhs) { - return .with(isEqual) - } - - /// Base case: For collections, we need to handle them differently, because - /// some collections like `Set` or `Dictionary` will not - /// return `Mirror.children` in a stable order. - - if let result = compareContentsIfSameTypeCollections(lhs, rhs) { - return .with(result == .equal) - } - - let lhsMirror = Mirror(reflecting: lhs) - - guard lhsMirror.children.isEmpty == false else { - - /// Values with no fields are technically always equal, but - /// we mark it with a special value for recursing through value trees - /// and eventually returning an error. - - return .hasNoFields - } - - let rhsMirror = Mirror(reflecting: rhs) - - /// Values with different child counts are not equal. This can happen - /// if the value type is providing its own mirror via `CustomReflectable`, - /// which Swift collections (like Array, Set, Dictionary) do. - - guard lhsMirror.children.count == rhsMirror.children.count else { - return .notEqual - } - - /// Enumerate each property by enumerating the value's `Mirror`. - - var hadEquatableProperty = false - - for (prop1, prop2) in zip(lhsMirror.children, rhsMirror.children) { - - /// 1) Check if the property is directly `Equatable` itself. - /// 2) If it's a `Collection`, we'll check the contents. - /// 3) If neither of those are true, recursively check children. - - if let result = isEqualIfEquatable(prop1.value, prop2.value) { - - /// If a property is `Equatable`, we can directly check it here. - - hadEquatableProperty = true - - if result == false { - return .notEqual - } - } else if let result = compareContentsIfSameTypeCollections(prop1.value, prop2.value) { - - /// If the properties were both collections, - /// and are the same type, they may be Equal. - - hadEquatableProperty = true - - if result != .equal { - return .notEqual - } - } else { - - /// Othewise, we will recursively check its child values. - - let result = compareEquatableProperties(prop1.value, prop2.value) - - switch result { - case .equal: - hadEquatableProperty ||= true - - case .notEqual: - hadEquatableProperty ||= true - return .notEqual - - case .hasNoFields: - hadEquatableProperty ||= false - - case .error: - hadEquatableProperty ||= false - } - } - } - - if hadEquatableProperty { - /// We made it through the entire list of properties, and found at least - /// one `Equatable` property, so we are equal. - return .equal - } else { - - let hasChildren = lhsMirror.children.count > 0 - - if hasChildren { - - /// We found no `Equatable` properties, but we did - /// have _some_ children, which our display state is likely - /// derived from (eg, closures or something). Report an error - /// and make the consumer implement `isEquivalent(to:)` themselves. - - return .error(.noEquatableProperties) - } else { - - /// We had no children at all, so we're equal. - - return .equal - } - } -} - - -@_spi(ListableInternal) -public enum CompareEquatablePropertiesResult : Equatable { - - case equal - case notEqual - case hasNoFields - case error(Error) - - public static func with(_ value: Bool) -> Self { - value ? .equal : .notEqual - } - - public enum Error : Equatable { - case noEquatableProperties - case unknownCollectionType(UnknownType) - - public struct UnknownType : Equatable { - - var unknownType : Any.Type - - public static func == (lhs: Self, rhs: Self) -> Bool { - type(of: lhs.unknownType) == type(of: rhs.unknownType) - } - } - } -} - - -/// Checks if the two provided values are the same type and Equatable. -private func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { - -#if swift(>=5.7) - if let lhs = lhs as? any Equatable { - return lhs.isEqual(to: rhs) - } else { - return false - } -#else - func check(value: Value) -> Bool { - if let typeInfo = Wrapped.self as? IsEquatableType.Type { - return typeInfo.isEqual(lhs: lhs, rhs: rhs) - } else { - return false - } - } - - /// This is the magic part of the whole process. Through `_openExistential`, - /// Swift will take the `Any` type (the existential type), and call the provided `body` - /// with the existential converted to the contained type. Because we have no constraint - /// on the contained type (just a `Value` generic), we can then check if the contained type - /// will conform to `IsEquatableType`. - /// - /// ``` - /// public func _openExistential( - /// _ existential: ExistentialType, - /// do body: (ContainedType) throws -> ResultType - /// ) rethrows -> ResultType - /// ``` - /// - /// https://github.com/apple/swift/blob/main/stdlib/public/core/Builtin.swift#L1005 - - return _openExistential(lhs, do: check) -#endif -} - - -/// Checks if the provided `value` is `Equatable`. -private func isEquatableValue(_ value: Any) -> Bool { - -#if swift(>=5.7) - return value is any Equatable -#else - func check(value: Value) -> Bool { - Wrapped.self is IsEquatableType.Type - } - - return _openExistential(value, do: check) -#endif -} - - -/// Checks if the provided `value` is a `Collection` -private func isACollection(_ value: Any) -> Bool { - -#if swift(>=5.7) - return value is any Collection -#else - func check(value: Value) -> Bool { - Wrapped.self is IsCollectionType.Type - } - - return _openExistential(value, do: check) -#endif -} - - -/// Checks if the provided `lhs` and `rhs` values are equal if they are `Equatable`. -private func isEqualIfEquatable(_ lhs: Any, _ rhs : Any) -> Bool? { - -#if swift(>=5.7) - if let lhs = lhs as? any Equatable { - return lhs.isEqual(to: rhs) - } else { - return nil - } -#else - func check(value: Value) -> Bool? { - if let typeInfo = Wrapped.self as? IsEquatableType.Type { - return typeInfo.isEqual(lhs: lhs, rhs: rhs) - } else { - return nil - } - } - - return _openExistential(lhs, do: check) -#endif -} - - -/// Checks if the provided `lhs` and `rhs` values are equal if they both the same type of `Collection`. -private func compareContentsIfSameTypeCollections(_ lhs: Any, _ rhs : Any) -> CompareEquatablePropertiesResult? { - - func check(value: Value) -> CompareEquatablePropertiesResult? { - if let typeInfo = Wrapped.self as? IsCollectionType.Type { - return typeInfo.compareContents(lhs: lhs, rhs: rhs) - } else { - return nil - } - } - - return _openExistential(lhs, do: check) -} - - -fileprivate enum Wrapped {} - - -#if swift(>=5.7) - -extension Equatable { - - fileprivate func isEqual(to other: Any) -> Bool { - guard let other = other as? Self else { - return false - } - - return self == other - } -} - -#else - - -private protocol IsEquatableType { - static func isEqual(lhs: Any, rhs: Any) -> Bool -} - - -extension Wrapped: IsEquatableType where Value: Equatable { - - fileprivate static func isEqual(lhs: Any, rhs: Any) -> Bool { - - guard let lhs = lhs as? Value, let rhs = rhs as? Value else { - return false - } - - return lhs == rhs - } -} - -#endif - -private protocol IsCollectionType { - - static func compareContents(lhs : Any, rhs : Any) -> CompareEquatablePropertiesResult -} - - -extension Wrapped: IsCollectionType where Value: Collection { - - static func compareContents(lhs : Any, rhs : Any) -> CompareEquatablePropertiesResult { - - guard let lhs = lhs as? ErasedComparableCollection else { - return .error(.unknownCollectionType(.init(unknownType: type(of: lhs)))) - } - - return .with(lhs.compareContents(to: rhs)) - } -} - -private protocol ErasedComparableCollection { - - func compareContents(to other : Any) -> Bool - -} - - -extension Dictionary : ErasedComparableCollection { - - fileprivate func compareContents(to other : Any) -> Bool { - - guard let other = other as? Self else { - return false - } - - guard count == other.count else { - return false - } - - for key in keys { - let lhs = self[key] - let rhs = other[key] - - guard let lhs = lhs, let rhs = rhs else { - return false - } - - if compareEquatableProperties(lhs, rhs) != .equal { - return false - } - } - - return true - } -} - - -extension Set : ErasedComparableCollection { - - fileprivate func compareContents(to other : Any) -> Bool { - - guard let other = other as? Self else { - return false - } - - guard count == other.count else { - return false - } - - for value in self { - if other.contains(value) == false { - return false - } - } - - return true - } -} - -extension Array : ErasedComparableCollection { - - fileprivate func compareContents(to other : Any) -> Bool { - - guard let other = other as? Self else { - return false - } - - guard count == other.count else { - return false - } - - for (index, lhs) in self.enumerated() { - let rhs = other[index] - - if compareEquatableProperties(lhs, rhs) != .equal { - return false - } - } - - return true - } -} - - -infix operator ||= - -fileprivate extension Bool { - - static func ||= (lhs : inout Bool, rhs : @autoclosure () -> Bool) { - lhs = lhs || rhs() - } -} diff --git a/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift b/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift similarity index 54% rename from ListableUI/Sources/IsEquivalent/EquivalentComparable.swift rename to ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift index 2bf9ac2d7..a477be52d 100644 --- a/ListableUI/Sources/IsEquivalent/EquivalentComparable.swift +++ b/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift @@ -1,5 +1,5 @@ // -// EquivalentComparable.swift +// LayoutEquivalent.swift // ListableUI // // Created by Kyle Van Essen on 11/28/21. @@ -12,16 +12,11 @@ import Foundation /// remeasure the content and re-layout the list. /// /// ## Note -/// You should rarely need to implement ``EquivalentComparable/isEquivalent(to:)-15tcq`` -/// yourself. By default, Listable will... -/// - For regular objects, compare all `Equatable` properties on your object to see if they changed. -/// - For `Equatable` objects, check to see if the object is equal. -/// -/// If you do need to implement this method yourself (eg, your object has no equatable properties, -/// or cannot conform to `Equatable`, see ``EquivalentComparable/isEquivalent(to:)-15tcq`` -/// for a full discussion of correct (and incorrect) implementations. -/// -public protocol EquivalentComparable { +/// If you conform to `Equatable`, your value will receive `LayoutEquivalent` +/// conformance for free. If you need to implement `LayoutEquivalent` manually, +/// consider using `LayoutKeyPathEquivalent` 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 @@ -90,66 +85,10 @@ public protocol EquivalentComparable { } -public extension EquivalentComparable -{ - /// Our default implementation compares the `Equatable` properties of the - /// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. - /// - func isEquivalent(to other : Self) -> Bool { - defaultIsEquivalentImplementation(self, other) - } -} - - -public extension EquivalentComparable where Self:Equatable +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 } } - - -@_spi(ListableInternal) -/// Our default implementation compares the `Equatable` properties of the -/// provided `Element` to approximate an `isEquivalent` or `Equatable` implementation. -public func defaultIsEquivalentImplementation(_ lhs : Value, _ rhs : Value) -> Bool { - let result = compareEquatableProperties(lhs, rhs) - - switch result { - case .equal: - return true - - case .notEqual: - return false - - case .hasNoFields: - return true - - case .error(let error): - - switch error { - case .noEquatableProperties, .unknownCollectionType: - assertionFailure( - """ - FAILURE: The default `isEquivalent(to:)` implementation could not find any `Equatable` properties \ - on \(Value.self), but there were other properties. - - In release versions, `isEquivalent(to:)` will always return false, which will affect performance. - - You should implement `isEquivalent(to:)` and check the relevant sub-properties to provide proper conformance: - - ``` - func isEquivalent(to other : Self) -> Bool { - myVar.subProperty == other.myVar.subProperty && ... - } - ``` - - If your object can conform to `Equatable`, you can also add that conformance to provide `isEquivalent(to:)`. - """ - ) - } - - return false - } -} diff --git a/ListableUI/Sources/IsEquivalent/LayoutKeyPathEquivalent.swift b/ListableUI/Sources/IsEquivalent/LayoutKeyPathEquivalent.swift new file mode 100644 index 000000000..78a5873d2 --- /dev/null +++ b/ListableUI/Sources/IsEquivalent/LayoutKeyPathEquivalent.swift @@ -0,0 +1,155 @@ +// +// LayoutKeyPathEquivalent.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 LayoutKeyPathEquivalent : LayoutEquivalent { + + typealias KeyPaths = [LayoutKeyPathEquivalentKeyPath] + + /// + /// 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 + /// `isEquivalentKeyPaths` 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 isEquivalentKeyPaths : KeyPaths { + /// // 🚫 Missing checks for title and detail. + /// // If they change, they likely affect sizing, + /// // which would result in incorrect item sizing. + /// + /// \.theme + /// } + /// + /// static var isEquivalentKeyPaths : 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 isEquivalentKeyPaths : 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`. + /// + @KeyPathEquivalentBuilder + static var isEquivalentKeyPaths : KeyPaths { get } +} + + +extension LayoutKeyPathEquivalent { + + /// Implements `isEquivalent(to:)` based on `isEquivalentKeyPaths`. + public func isEquivalent(to other: Self) -> Bool { + + let keyPaths = Self.isEquivalentKeyPaths + + for keyPath in keyPaths { + if keyPath.compare(self, other) == false { + return false + } + } + + return true + } +} + + +public struct LayoutKeyPathEquivalentKeyPath { + + 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 KeyPathEquivalentBuilder { + + // 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 `LayoutKeyPathEquivalent`.") + public static func buildExpression( + _ keyPath: KeyPath + ) -> Component { + fatalError() + } + + public static func buildBlock(_ component: Component...) -> Component { + component.flatMap { $0 } + } +} diff --git a/ListableUI/Sources/Item/ItemContent.swift b/ListableUI/Sources/Item/ItemContent.swift index 2e711f67d..c6e993279 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 : EquivalentComparable, AnyItemConvertible where Coordinator.ItemContentType == Self +public protocol ItemContent : LayoutEquivalent, AnyItemConvertible where Coordinator.ItemContentType == Self { // // MARK: Identification diff --git a/ListableUI/Tests/ComparePropertiesTests.swift b/ListableUI/Tests/ComparePropertiesTests.swift deleted file mode 100644 index e336aaf1a..000000000 --- a/ListableUI/Tests/ComparePropertiesTests.swift +++ /dev/null @@ -1,610 +0,0 @@ -// -// ComparePropertiesTests.swift -// ListableUI-Unit-Tests -// -// Created by Kyle Van Essen on 7/28/22. -// - -@_spi(ListableInternal) import ListableUI -import XCTest - - -class ComparePropertiesTests : XCTestCase { - - func test_compare_string() { - - XCTAssertEqual( - .equal, - compareEquatableProperties( - "A String", - "A String" - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - "A String", - "A String!" - ) - ) - - XCTAssertEqual( - .equal, - compareEquatableProperties( - "A String" as Any, - "A String" as Any - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - "A String" as Any, - "A String!" as Any - ) - ) - } - - func test_compare_int() { - - XCTAssertTrue(_isPOD(Int.self)) - - XCTAssertEqual( - .equal, - compareEquatableProperties( - 10, - 10 - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - 10, - 11 - ) - ) - - XCTAssertEqual( - .equal, - compareEquatableProperties( - 10 as Any, - 10 as Any - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - 10 as Any, - 11 as Any - ) - ) - - } - - func test_empty() { - - XCTAssertEqual( - .hasNoFields, - compareEquatableProperties( - EmptyValue(), - EmptyValue() - ) - ) - - } - - func test_compare_non_equatable_values() { - - XCTAssertEqual( - .equal, - compareEquatableProperties( - TestValue( - title: "A Title", - detail: "Some Detail", - count: 10, - enumValue: .foo, - closure: {} - ), - TestValue( - title: "A Title", - detail: "Some Detail", - count: 10, - enumValue: .foo, - closure: {} - ) - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - TestValue( - title: "A Different Title", - detail: "Some Detail", - count: 10, - enumValue: .foo, - closure: {} - ), - TestValue( - title: "A Title", - detail: "Some Detail", - count: 10, - enumValue: .foo, - closure: {} - ) - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - TestValue( - title: "A Title", - detail: "Some Detail", - count: 10, - enumValue: .foo, - closure: {} - ), - TestValue( - title: "A Title", - detail: "Some Detail", - count: 10, - enumValue: .bar("A String"), - closure: {} - ) - ) - ) - - // Ensure we message that there were no Equatable properties to compare. - - XCTAssertEqual( - .error(.noEquatableProperties), - compareEquatableProperties( - TestValueWithNoEquatableProperties(), - TestValueWithNoEquatableProperties() - ) - ) - - // Check what happens with a non-Equatable enum, but it has associated types which may be Equatable. - - XCTAssertEqual( - .error(.noEquatableProperties), - compareEquatableProperties( - NonEquatableEnumOnlyValue(enumValue: .one), - NonEquatableEnumOnlyValue(enumValue: .one) - ) - ) - - XCTAssertEqual( - .error(.noEquatableProperties), - compareEquatableProperties( - NonEquatableEnumOnlyValue(enumValue: .one), - NonEquatableEnumOnlyValue(enumValue: .two) - ) - ) - - XCTAssertEqual( - .equal, - compareEquatableProperties( - NonEquatableEnumOnlyValue(enumValue: .three("Hello")), - NonEquatableEnumOnlyValue(enumValue: .three("Hello")) - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - NonEquatableEnumOnlyValue(enumValue: .three("Hello")), - NonEquatableEnumOnlyValue(enumValue: .three("Hello!!")) - ) - ) - - // ⚠️ Swift 5.6 and earlier bug: Swift cannot resolve these two strings as an `Equatable` type. - // This seems to be a bug in how `Any` instances can get cast back to a strict type - // through `_openExistential`. This is resolved in Swift 5.7 / Xcode 14. - // - // Once we support Xcode 14 and later only, we can remove the `#else` branches here and in `ComparableProperties.swift`. - -#if swift(>=5.7) - XCTAssertEqual( - .equal, - compareEquatableProperties( - NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))), - NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))) - ) - ) -#else - XCTAssertEqual( - .error(.noEquatableProperties), - compareEquatableProperties( - NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))), - NonEquatableEnumOnlyValue(enumValue: .four(.init(value: "Some Value"))) - ) - ) -#endif - -#if swift(>=5.7) - XCTAssertEqual( - .equal, - compareEquatableProperties( - NonEquatableEnumOnlyValue(enumValue: .five("Some String")), - NonEquatableEnumOnlyValue(enumValue: .five("Some String")) - ) - ) -#else - XCTAssertEqual( - .error(.noEquatableProperties), - compareEquatableProperties( - NonEquatableEnumOnlyValue(enumValue: .five("Some String")), - NonEquatableEnumOnlyValue(enumValue: .five("Some String")) - ) - ) -#endif - -#if swift(>=5.7) - XCTAssertEqual( - .equal, - compareEquatableProperties( - NonEquatableEnumOnlyValue(enumValue: .five(1)), - NonEquatableEnumOnlyValue(enumValue: .five(1)) - ) - ) -#else - XCTAssertEqual( - .error(.noEquatableProperties), - compareEquatableProperties( - NonEquatableEnumOnlyValue(enumValue: .five(1)), - NonEquatableEnumOnlyValue(enumValue: .five(1)) - ) - ) -#endif - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - NonEquatableEnumOnlyValue(enumValue: .five("Some String")), - NonEquatableEnumOnlyValue(enumValue: .five(1)) - ) - ) - - } - - func test_compare_equatable_values() { - - XCTAssertEqual( - .equal, - compareEquatableProperties( - EquatableValue( - title: "A Title", - detail: "Some Detail", - count: 10 - ), - EquatableValue( - title: "A Title", - detail: "Some Detail", - count: 10 - ) - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - EquatableValue( - title: "A Title", - detail: "Some Detail", - count: 10 - ), - EquatableValue( - title: "Another Title", - detail: "Some Detail", - count: 10 - ) - ) - ) - } - - func test_array() { - - XCTAssertEqual( - .equal, - compareEquatableProperties( - [], - [] - ) - ) - - XCTAssertEqual( - .equal, - compareEquatableProperties( - ["A", "B"], - ["A", "B"] - ) - ) - - XCTAssertEqual( - .equal, - compareEquatableProperties( - ["A", "B"], - ["A", "B"] - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - ["A", "B"], - ["A", "B", "C"] - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - ["A", "2"], - ["A", 2] - ) - ) - } - - func test_set() { - - XCTAssertEqual( - .equal, - compareEquatableProperties( - Set(), - Set() - ) - ) - - XCTAssertEqual( - .equal, - compareEquatableProperties( - Set(["A", "B"]), - Set(["A", "B"]) - ) - ) - - XCTAssertEqual( - .equal, - compareEquatableProperties( - Set(["A", "B"]), - Set(["B", "A"]) - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - Set(["A", "B"]), - Set(["A", "B", "C"]) - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - Set(["A", "2"]), - Set(["A", 2]) - ) - ) - } - - func test_dictionary() { - - XCTAssertEqual( - .equal, - compareEquatableProperties( - Dictionary(), - Dictionary() - ) - ) - - XCTAssertEqual( - .equal, - compareEquatableProperties( - ["Key1": 1, "Key2" : 2] as Dictionary, - ["Key1": 1, "Key2" : 2] as Dictionary - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - ["Key1": 1, "Key2" : 3] as Dictionary, - ["Key1": 1, "Key3" : 2] as Dictionary - ) - ) - - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - ["Key1": 1, "Key2" : 2] as Dictionary, - ["Key1": 1, "Key3" : 2] as Dictionary - ) - ) - - // ⚠️ Swift 5.6 and earlier bug: Swift cannot resolve these two strings as an `Equatable` type. - // This seems to be a bug in how `Any` instances can get cast back to a strict type - // through `_openExistential`. This is resolved in Swift 5.7 / Xcode 14. - // - // Once we support Xcode 14 and later only, we can remove the `#else` branches here and in `ComparableProperties.swift`. - -#if swift(>=5.7) - XCTAssertEqual( - .equal, - compareEquatableProperties( - ["Key1": 1, "Key2" : 2] as Dictionary, - ["Key1": 1, "Key2" : 2] as Dictionary - ) - ) -#else - XCTAssertEqual( - .notEqual, - compareEquatableProperties( - ["Key1": 1, "Key2" : 2] as Dictionary, - ["Key1": 1, "Key2" : 2] as Dictionary - ) - ) -#endif - } - - func test_performance() { - - print("Compare based on properties...") - - determineAverage(for: 1.0) { - _ = compareEquatableProperties( - TestValue( - title: "A Title", - count: 10, - enumValue: .foo, - closure: {} - ), - TestValue( - title: "A Title", - count: 10, - enumValue: .foo, - closure: {} - ) - ) - } - - print("Compare based on properties, removing enum...") - - determineAverage(for: 1.0) { - _ = compareEquatableProperties( - TestValue.ButNoEnums( - title: "A Title", - count: 10, - closure: {} - ), - TestValue.ButNoEnums( - title: "A Title", - count: 10, - closure: {} - ) - ) - } - - print("Compare based on synthesized Equatable implementation...") - - determineAverage(for: 1.0) { - _ = compareEquatableProperties( - TestValue.ButEquatable( - title: "A Title", - count: 10, - enumValue: .foo - ), - TestValue.ButEquatable( - title: "A Title", - count: 10, - enumValue: .foo - ) - ) - } - } -} - - -fileprivate struct TestValue { - - var title : String - var detail : String? - var count : Int - - var enumValue : EnumValue - - var nonEquatable: NonEquatableValue = .init(value: "An inner string") - - var closure : () -> () - - enum EnumValue : Equatable { - case foo - case bar(String) - } - - /// Removing enums - - fileprivate struct ButNoEnums { - - var title : String - var detail : String? - var count : Int - - var nonEquatable: EquatableValue = .init(value: "An inner string") - - var closure : () -> () - - fileprivate struct EquatableValue : Equatable { - - var value : AnyHashable - } - } - - /// Mirrors the structure of the parent type but with a synthesized `Equatable` - /// implementation to compare performance. - - fileprivate struct ButEquatable : Equatable { - - var title : String - var detail : String? - var count : Int - - var enumValue : EnumValue - - var nonEquatable: EquatableValue = .init(value: "An inner string") - - enum EnumValue : Equatable { - case foo - case bar(String) - } - - fileprivate struct EquatableValue : Equatable { - - var value : AnyHashable - } - } -} - - -fileprivate struct EquatableValue : Equatable { - - var title : String - var detail : String? - var count : Int -} - - -fileprivate struct NonEquatableValue { - - var value : Any -} - - -fileprivate struct TestValueWithNoEquatableProperties { - - var closure1 : () -> () = {} - var closure2 : () -> () = {} - -} - - -fileprivate struct EmptyValue {} - - -fileprivate struct NonEquatableEnumOnlyValue { - - var enumValue : AnEnum - - enum AnEnum { - case one - case two - case three(String) - case four(NonEquatableValue) - case five(Any) - } -} diff --git a/ListableUI/Tests/LayoutKeyPathEquivalent.swift b/ListableUI/Tests/LayoutKeyPathEquivalent.swift new file mode 100644 index 000000000..8855a9da2 --- /dev/null +++ b/ListableUI/Tests/LayoutKeyPathEquivalent.swift @@ -0,0 +1,53 @@ +// +// LayoutKeyPathEquivalentTests.swift +// ListableUI-Unit-Tests +// +// Created by Kyle Van Essen on 5/24/23. +// + +import ListableUI +import XCTest + +class LayoutKeyPathEquivalentTests : XCTestCase { + + func test_isEquivalent() { + + struct TestingThing : LayoutKeyPathEquivalent { + + var name : String + var age : Int + var birthdate : Date + var nonCompared : Bool + + static var isEquivalentKeyPaths: 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)) + } +} diff --git a/ListableUI/Tests/ListableBuilderTests.swift b/ListableUI/Tests/ListableBuilderTests.swift index dcc6d5a10..18fc652b8 100644 --- a/ListableUI/Tests/ListableBuilderTests.swift +++ b/ListableUI/Tests/ListableBuilderTests.swift @@ -292,7 +292,7 @@ fileprivate struct EquatableContent : ItemContent, Equatable { } -fileprivate struct EquivalentContent : ItemContent, EquivalentComparable { +fileprivate struct EquivalentContent : ItemContent, LayoutEquivalent { var identifierValue: String { "" @@ -330,7 +330,7 @@ fileprivate struct EquatableHeaderFooter : HeaderFooterContent, Equatable { } -fileprivate struct EquivalentHeaderFooter : HeaderFooterContent, EquivalentComparable { +fileprivate struct EquivalentHeaderFooter : HeaderFooterContent, LayoutEquivalent { var calledIsEquivalent : () -> () From 938ef81eaf17e4140c29f29d0c3aa79ed0f4c472 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 25 May 2023 16:29:42 -0700 Subject: [PATCH 32/38] Additional renames --- BlueprintUILists/Sources/Element+Item.swift | 1 + BlueprintUILists/Sources/Section+Element.swift | 1 + CHANGELOG.md | 2 +- Demo/Demo.xcodeproj/project.pbxproj | 2 +- ...ent.swift => KeyPathLayoutEquivalent.swift} | 18 +++++++++--------- .../IsEquivalent/LayoutEquivalent.swift | 2 +- ...wift => KeyPathLayoutEquivalentTests.swift} | 6 +++--- 7 files changed, 17 insertions(+), 15 deletions(-) rename ListableUI/Sources/IsEquivalent/{LayoutKeyPathEquivalent.swift => KeyPathLayoutEquivalent.swift} (89%) rename ListableUI/Tests/{LayoutKeyPathEquivalent.swift => KeyPathLayoutEquivalentTests.swift} (88%) diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index df495a4ad..417fa0dce 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -43,6 +43,7 @@ extension Element where Self:Equatable { /// Ensures that the `LayoutEquivalent` initializer for `WrappedElementContent` is called. extension Element where Self:LayoutEquivalent { + @_disfavoredOverload public func listItem( id : AnyHashable? = nil, selection: ItemSelectionStyle = .notSelectable, diff --git a/BlueprintUILists/Sources/Section+Element.swift b/BlueprintUILists/Sources/Section+Element.swift index 8dcc48bc5..4e8e1c608 100644 --- a/BlueprintUILists/Sources/Section+Element.swift +++ b/BlueprintUILists/Sources/Section+Element.swift @@ -32,6 +32,7 @@ extension Section { /// section.add(Element2()) /// } /// ``` + @_disfavoredOverload public mutating func add(_ element : ElementType) where ElementType:LayoutEquivalent { self.items.append(element.listItem()) diff --git a/CHANGELOG.md b/CHANGELOG.md index 186899ca9..29697ec83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### Added -- Adding Blueprint Elements to your list content has become easier: Just add them directly! You no longer need to use the `ElementItem` wrapper, unless you need to provide an isEquivalent implementation, a background view, or a selected background view, in which case, you are encouraged to use `BlueprintItemContent` or `BlueprintHeaderFooterContent` directly. The `ElementItem` and `ElementHeaderFooter` APIs will be deprecated in a future release, and are now soft-deprecated. +- 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") { diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 21056d932..918c8312d 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -585,7 +585,7 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = s; + GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", diff --git a/ListableUI/Sources/IsEquivalent/LayoutKeyPathEquivalent.swift b/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift similarity index 89% rename from ListableUI/Sources/IsEquivalent/LayoutKeyPathEquivalent.swift rename to ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift index 78a5873d2..34bb925a1 100644 --- a/ListableUI/Sources/IsEquivalent/LayoutKeyPathEquivalent.swift +++ b/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift @@ -1,5 +1,5 @@ // -// LayoutKeyPathEquivalent.swift +// KeyPathLayoutEquivalent.swift // ListableUI // // Created by Kyle Van Essen on 5/24/23. @@ -14,9 +14,10 @@ import Foundation /// ## Note /// If you conform to `Equatable`, your value will receive `LayoutEquivalent` /// conformance for free. -public protocol LayoutKeyPathEquivalent : LayoutEquivalent { +public protocol KeyPathLayoutEquivalent : LayoutEquivalent { - typealias KeyPaths = [LayoutKeyPathEquivalentKeyPath] + typealias KeyPaths = [KeyPathLayoutEquivalentKeyPath] + typealias Builder = KeyPathLayoutEquivalentBuilder /// /// Used by the list to determine when the content of content has changed; in order to @@ -83,12 +84,11 @@ public protocol LayoutKeyPathEquivalent : LayoutEquivalent { /// If your ``ItemContent`` conforms to ``Equatable``, there is a default /// implementation of this method which simply returns `self == other`. /// - @KeyPathEquivalentBuilder - static var isEquivalentKeyPaths : KeyPaths { get } + @Builder static var isEquivalentKeyPaths : KeyPaths { get } } -extension LayoutKeyPathEquivalent { +extension KeyPathLayoutEquivalent { /// Implements `isEquivalent(to:)` based on `isEquivalentKeyPaths`. public func isEquivalent(to other: Self) -> Bool { @@ -106,7 +106,7 @@ extension LayoutKeyPathEquivalent { } -public struct LayoutKeyPathEquivalentKeyPath { +public struct KeyPathLayoutEquivalentKeyPath { let compare : (Value, Value) -> Bool @@ -124,7 +124,7 @@ public struct LayoutKeyPathEquivalentKeyPath { } -@resultBuilder public struct KeyPathEquivalentBuilder { +@resultBuilder public struct KeyPathLayoutEquivalentBuilder { // https://github.com/apple/swift-evolution/blob/main/proposals/0289-result-builders.md @@ -142,7 +142,7 @@ public struct LayoutKeyPathEquivalentKeyPath { [.init(keyPath)] } - @available(*, unavailable, message: "A KeyPath must conform to Equatable or LayoutEquivalent to be used with `LayoutKeyPathEquivalent`.") + @available(*, unavailable, message: "A KeyPath must conform to Equatable or LayoutEquivalent to be used with `KeyPathLayoutEquivalent`.") public static func buildExpression( _ keyPath: KeyPath ) -> Component { diff --git a/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift b/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift index a477be52d..1b07e6e4e 100644 --- a/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift +++ b/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift @@ -14,7 +14,7 @@ import Foundation /// ## Note /// If you conform to `Equatable`, your value will receive `LayoutEquivalent` /// conformance for free. If you need to implement `LayoutEquivalent` manually, -/// consider using `LayoutKeyPathEquivalent` as a more declarative way to denote +/// consider using `KeyPathLayoutEquivalent` as a more declarative way to denote /// which key paths should be used in the `isEquivalent(to:)` comparison public protocol LayoutEquivalent { diff --git a/ListableUI/Tests/LayoutKeyPathEquivalent.swift b/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift similarity index 88% rename from ListableUI/Tests/LayoutKeyPathEquivalent.swift rename to ListableUI/Tests/KeyPathLayoutEquivalentTests.swift index 8855a9da2..1ff4645f6 100644 --- a/ListableUI/Tests/LayoutKeyPathEquivalent.swift +++ b/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift @@ -1,5 +1,5 @@ // -// LayoutKeyPathEquivalentTests.swift +// KeyPathLayoutEquivalentTests.swift // ListableUI-Unit-Tests // // Created by Kyle Van Essen on 5/24/23. @@ -8,11 +8,11 @@ import ListableUI import XCTest -class LayoutKeyPathEquivalentTests : XCTestCase { +class KeyPathLayoutEquivalentTests : XCTestCase { func test_isEquivalent() { - struct TestingThing : LayoutKeyPathEquivalent { + struct TestingThing : KeyPathLayoutEquivalent { var name : String var age : Int From 58e8a6f98f3e108a63ef8f21b92318d1e62350ab Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 25 May 2023 17:11:32 -0700 Subject: [PATCH 33/38] Improve error messages --- .../Sources/Element+HeaderFooter.swift | 13 +++++++++++++ BlueprintUILists/Sources/Element+Item.swift | 15 +++++++++++++++ .../Sources/ListableBuilder+Element.swift | 12 ++++++++++++ .../Tests/ListableBuilder+ElementTests.swift | 14 +++++++++++++- 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index b91676a77..907fcf5b1 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -12,6 +12,19 @@ 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 { diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index 417fa0dce..d3dccb398 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -12,6 +12,21 @@ 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 { diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index a3a87fc61..4361eeec8 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -21,6 +21,12 @@ import ListableUI /// ``` 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()] @@ -40,6 +46,12 @@ public extension ListableArrayBuilder where ContentType == AnyItemConvertible { public extension ListableValueBuilder 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 { diff --git a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift index a4aa564a2..f7b7539b3 100644 --- a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift +++ b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift @@ -184,6 +184,14 @@ fileprivate struct NonConvertibleElement : ProxyElement, ListElementNonConvertib } +fileprivate struct NonIncludableElement : ProxyElement { + + var elementRepresentation: Element { + Empty() + } +} + + fileprivate struct Element1 : ProxyElement, Equatable, LayoutEquivalent { var elementRepresentation: Element { @@ -242,7 +250,11 @@ fileprivate struct TestContent1 : BlueprintItemContent, Equatable { } -fileprivate struct TestContent2 : BlueprintItemContent, Equatable { +fileprivate struct TestContent2 : BlueprintItemContent, LayoutEquivalent { + + func isEquivalent(to other: TestContent2) -> Bool { + true + } var identifierValue: String { "1" From 5c9de6d107f560f3b0cc5a52bd6aad66cd14b706 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 25 May 2023 17:35:52 -0700 Subject: [PATCH 34/38] Remove applies overrides --- BlueprintUILists/Sources/Element+HeaderFooter.swift | 4 ---- BlueprintUILists/Sources/Element+Item.swift | 4 ---- 2 files changed, 8 deletions(-) diff --git a/BlueprintUILists/Sources/Element+HeaderFooter.swift b/BlueprintUILists/Sources/Element+HeaderFooter.swift index 907fcf5b1..1eef7a18d 100644 --- a/BlueprintUILists/Sources/Element+HeaderFooter.swift +++ b/BlueprintUILists/Sources/Element+HeaderFooter.swift @@ -123,8 +123,4 @@ public struct WrappedHeaderFooterContent : BlueprintHeaderF public var pressedBackground: Element? { pressedBackgroundProvider() } - - public var reappliesToVisibleView: ReappliesToVisibleView { - .ifNotEquivalent - } } diff --git a/BlueprintUILists/Sources/Element+Item.swift b/BlueprintUILists/Sources/Element+Item.swift index d3dccb398..36dfcdd90 100644 --- a/BlueprintUILists/Sources/Element+Item.swift +++ b/BlueprintUILists/Sources/Element+Item.swift @@ -146,8 +146,4 @@ public struct WrappedElementContent : BlueprintItemContent public func selectedBackgroundElement(with info: ApplyItemContentInfo) -> Element? { selectedBackgroundProvider(info) } - - public var reappliesToVisibleView: ReappliesToVisibleView { - .ifNotEquivalent - } } From 59bce3b759ab8e9198e7c09e3b01674bf9c2cb0e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 25 May 2023 17:42:21 -0700 Subject: [PATCH 35/38] Update demos to use key path equivalency --- ...tionViewDictionaryDemoViewController.swift | 6 ++--- ...cesPaymentScheduleDemoViewController.swift | 24 ++++++++----------- .../ItemizationEditorViewController.swift | 7 +++--- .../PaymentTypesViewController.swift | 6 ++--- 4 files changed, 19 insertions(+), 24 deletions(-) diff --git a/Demo/Sources/Demos/Demo Screens/CollectionViewDictionaryDemoViewController.swift b/Demo/Sources/Demos/Demo Screens/CollectionViewDictionaryDemoViewController.swift index 872a536a9..828a52413 100644 --- a/Demo/Sources/Demos/Demo Screens/CollectionViewDictionaryDemoViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/CollectionViewDictionaryDemoViewController.swift @@ -143,7 +143,7 @@ final public class CollectionViewDictionaryDemoViewController : 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 isEquivalentKeyPaths: KeyPaths { + \.text } typealias ContentView = SearchBar diff --git a/Demo/Sources/Demos/Demo Screens/InvoicesPaymentScheduleDemoViewController.swift b/Demo/Sources/Demos/Demo Screens/InvoicesPaymentScheduleDemoViewController.swift index 653851ed3..0d381a0c4 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 isEquivalentKeyPaths: 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 isEquivalentKeyPaths: 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 isEquivalentKeyPaths: KeyPaths { + \.text } } diff --git a/Demo/Sources/Demos/Demo Screens/ItemizationEditorViewController.swift b/Demo/Sources/Demos/Demo Screens/ItemizationEditorViewController.swift index feb0f0398..040689722 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 isEquivalentKeyPaths: KeyPaths { + \.content } var identifierValue: String { diff --git a/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift b/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift index 926e7fd83..9b933215d 100644 --- a/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift @@ -161,7 +161,7 @@ fileprivate struct EmptyRow : BlueprintItemContent, Equatable { } } -fileprivate struct PaymentTypeRow : BlueprintItemContent { +fileprivate struct PaymentTypeRow : BlueprintItemContent, KeyPathLayoutEquivalent { var type : PaymentType @@ -214,8 +214,8 @@ fileprivate struct PaymentTypeRow : BlueprintItemContent { ) } - func isEquivalent(to other: PaymentTypeRow) -> Bool { - self.type == other.type + static var isEquivalentKeyPaths: KeyPaths { + \.type } } From 40179f7fed4e484beb7d7b417087d204c49b8ce4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 11 Jun 2023 16:28:58 -0700 Subject: [PATCH 36/38] Self review and cleanup --- BlueprintUILists/Sources/List.swift | 8 ++-- .../Sources/ListableBuilder+Element.swift | 2 +- CHANGELOG.md | 4 +- .../AnyHeaderFooterConvertible.swift | 2 + .../KeyPathLayoutEquivalent.swift | 16 ++++++- .../IsEquivalent/LayoutEquivalent.swift | 2 +- ListableUI/Sources/ListableBuilder.swift | 22 +++++++-- ListableUI/Sources/Section/Section.swift | 4 +- .../Tests/KeyPathLayoutEquivalentTests.swift | 6 +++ ListableUI/Tests/ListableBuilderTests.swift | 48 +++++++++++++++++-- 10 files changed, 93 insertions(+), 21 deletions(-) diff --git a/BlueprintUILists/Sources/List.swift b/BlueprintUILists/Sources/List.swift index 5a86c6646..62bf74269 100644 --- a/BlueprintUILists/Sources/List.swift +++ b/BlueprintUILists/Sources/List.swift @@ -77,10 +77,10 @@ public struct List : Element measurement : List.Measurement = .fillParent, configure : ListProperties.Configure = { _ in }, @ListableArrayBuilder
sections : () -> [Section], - @ListableValueBuilder containerHeader : () -> AnyHeaderFooterConvertible? = { nil }, - @ListableValueBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, - @ListableValueBuilder footer : () -> AnyHeaderFooterConvertible? = { nil }, - @ListableValueBuilder overscrollFooter : () -> AnyHeaderFooterConvertible? = { nil } + @AnyHeaderFooterBuilder containerHeader : () -> AnyHeaderFooterConvertible? = { nil }, + @AnyHeaderFooterBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, + @AnyHeaderFooterBuilder footer : () -> AnyHeaderFooterConvertible? = { nil }, + @AnyHeaderFooterBuilder overscrollFooter : () -> AnyHeaderFooterConvertible? = { nil } ) { self.measurement = measurement diff --git a/BlueprintUILists/Sources/ListableBuilder+Element.swift b/BlueprintUILists/Sources/ListableBuilder+Element.swift index 4361eeec8..5d46ba8a1 100644 --- a/BlueprintUILists/Sources/ListableBuilder+Element.swift +++ b/BlueprintUILists/Sources/ListableBuilder+Element.swift @@ -45,7 +45,7 @@ public extension ListableArrayBuilder where ContentType == AnyItemConvertible { } -public extension ListableValueBuilder where ContentType == AnyHeaderFooterConvertible { +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.") diff --git a/CHANGELOG.md b/CHANGELOG.md index 29697ec83..ebed4e1c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ } ``` -- Added `ListableValueBuilder`, a result builder for single-value results. +- Added `ListableOptionalBuilder`, a result builder for single-value results. ### Removed @@ -31,8 +31,6 @@ - The `ListableBuilder` result builder is now `ListableArrayBuilder`. -- In many cases, you no longer need to implement `isEquivalent(to:)`. Listable will traverse the `Equatable` properties on your contents to determine when content should be re-measured. To improve performance for long lists or complex content objects, you're still encouraged to either make those content objects conform to `Equatable`, or implement `isEquivalent(to:)`. - ### Misc # Past Releases 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/IsEquivalent/KeyPathLayoutEquivalent.swift b/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift index 34bb925a1..6f48db672 100644 --- a/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift +++ b/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift @@ -88,12 +88,24 @@ public protocol KeyPathLayoutEquivalent : LayoutEquivalent { } +fileprivate var cachedIsEquivalentKeyPaths : [ObjectIdentifier:Any] = [:] + extension KeyPathLayoutEquivalent { /// Implements `isEquivalent(to:)` based on `isEquivalentKeyPaths`. public func isEquivalent(to other: Self) -> Bool { - - let keyPaths = Self.isEquivalentKeyPaths + + let keyPaths : KeyPaths = { + let id = ObjectIdentifier(Self.self) + + if let existing = cachedIsEquivalentKeyPaths[id] { + return existing as! KeyPaths + } else { + let new = Self.isEquivalentKeyPaths + cachedIsEquivalentKeyPaths[id] = new + return new + } + }() for keyPath in keyPaths { if keyPath.compare(self, other) == false { diff --git a/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift b/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift index 1b07e6e4e..8bfee9c4c 100644 --- a/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift +++ b/ListableUI/Sources/IsEquivalent/LayoutEquivalent.swift @@ -15,7 +15,7 @@ import Foundation /// 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 +/// which key paths should be used in the `isEquivalent(to:)` comparison. public protocol LayoutEquivalent { /// diff --git a/ListableUI/Sources/ListableBuilder.swift b/ListableUI/Sources/ListableBuilder.swift index 2e34587fb..3475998cc 100644 --- a/ListableUI/Sources/ListableBuilder.swift +++ b/ListableUI/Sources/ListableBuilder.swift @@ -94,7 +94,7 @@ /// You provide a result builder in an API by specifying it as a method parameter, like so: /// /// ``` -/// init(@ListableValueBuilder thing : () -> SomeContent) { +/// init(@ListableOptionalBuilder thing : () -> SomeContent?) { /// self.thing = thing() /// } /// ``` @@ -105,13 +105,27 @@ /// https://www.swiftbysundell.com/articles/deep-dive-into-swift-function-builders/ /// https://www.avanderlee.com/swift/result-builders/ /// -@resultBuilder public enum ListableValueBuilder { - +@resultBuilder public enum ListableOptionalBuilder { + + typealias Component = ContentType? + public static func buildBlock() -> ContentType? { nil } public static func buildBlock(_ content: ContentType?) -> ContentType? { - return content + 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 1bf395e4d..3395ae489 100644 --- a/ListableUI/Sources/Section/Section.swift +++ b/ListableUI/Sources/Section/Section.swift @@ -121,8 +121,8 @@ public struct Section layouts : SectionLayouts = .init(), reordering : SectionReordering = .init(), @ListableArrayBuilder items : () -> [AnyItemConvertible], - @ListableValueBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, - @ListableValueBuilder footer : () -> AnyHeaderFooterConvertible? = { nil } + @AnyHeaderFooterBuilder header : () -> AnyHeaderFooterConvertible? = { nil }, + @AnyHeaderFooterBuilder footer : () -> AnyHeaderFooterConvertible? = { nil } ) { self.identifier = Identifier(identifier) diff --git a/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift b/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift index 1ff4645f6..2ea182da6 100644 --- a/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift +++ b/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift @@ -49,5 +49,11 @@ class KeyPathLayoutEquivalentTests : XCTestCase { XCTAssertTrue(value1.isEquivalent(to: equivalentToValue1)) XCTAssertFalse(value1.isEquivalent(to: notEquivalentToValue1)) + + /// Our implementation caches the result of `isEquivalentKeyPaths`, + /// 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 18fc652b8..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 {} @@ -210,6 +210,46 @@ class ListableBuilderTests : XCTestCase { } } + 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 @@ -248,9 +288,9 @@ class ListableBuilderTests : XCTestCase { XCTAssertEqual(callCount, 4) } - fileprivate func build( - @ListableArrayBuilder using builder : () -> [Content] - ) -> [Content] + private func build( + @ListableOptionalBuilder using builder : () -> Content? + ) -> Content? { builder() } From e3c5ce41e961a279f1a4121b83ddf7ebbac1b9cd Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 15 Jun 2023 15:52:34 -0700 Subject: [PATCH 37/38] KeyPathEquivalent updates --- CHANGELOG.md | 20 ++++++++++++- ...tionViewDictionaryDemoViewController.swift | 2 +- ...cesPaymentScheduleDemoViewController.swift | 6 ++-- .../ItemizationEditorViewController.swift | 2 +- .../PaymentTypesViewController.swift | 2 +- .../KeyPathLayoutEquivalent.swift | 14 +++++----- .../Tests/KeyPathLayoutEquivalentTests.swift | 28 +++++++++---------- 7 files changed, 46 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebed4e1c3..a6273ba34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,25 @@ } ``` -- Added `ListableOptionalBuilder`, a result builder for single-value results. +- 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 diff --git a/Demo/Sources/Demos/Demo Screens/CollectionViewDictionaryDemoViewController.swift b/Demo/Sources/Demos/Demo Screens/CollectionViewDictionaryDemoViewController.swift index 828a52413..821512f09 100644 --- a/Demo/Sources/Demos/Demo Screens/CollectionViewDictionaryDemoViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/CollectionViewDictionaryDemoViewController.swift @@ -161,7 +161,7 @@ fileprivate struct SearchBarElement : ItemContent, KeyPathLayoutEquivalent views.content.text = self.text } - static var isEquivalentKeyPaths: KeyPaths { + static var isEquivalent: KeyPaths { \.text } diff --git a/Demo/Sources/Demos/Demo Screens/InvoicesPaymentScheduleDemoViewController.swift b/Demo/Sources/Demos/Demo Screens/InvoicesPaymentScheduleDemoViewController.swift index 0d381a0c4..3aae7c38f 100644 --- a/Demo/Sources/Demos/Demo Screens/InvoicesPaymentScheduleDemoViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/InvoicesPaymentScheduleDemoViewController.swift @@ -273,7 +273,7 @@ fileprivate struct ToggleRow : BlueprintItemContent, KeyPathLayoutEquivalent self.content.text } - static var isEquivalentKeyPaths: KeyPaths { + static var isEquivalent: KeyPaths { \.content } } @@ -388,7 +388,7 @@ fileprivate struct AmountRow : BlueprintItemContent, KeyPathLayoutEquivalent self.content.title } - static var isEquivalentKeyPaths: KeyPaths { + static var isEquivalent: KeyPaths { \.content } } @@ -407,7 +407,7 @@ fileprivate struct ButtonRow : BlueprintItemContent, KeyPathLayoutEquivalent self.text } - static var isEquivalentKeyPaths: KeyPaths { + static var isEquivalent: KeyPaths { \.text } } diff --git a/Demo/Sources/Demos/Demo Screens/ItemizationEditorViewController.swift b/Demo/Sources/Demos/Demo Screens/ItemizationEditorViewController.swift index 040689722..a19851d00 100644 --- a/Demo/Sources/Demos/Demo Screens/ItemizationEditorViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/ItemizationEditorViewController.swift @@ -253,7 +253,7 @@ struct ToggleItem : BlueprintItemContent, KeyPathLayoutEquivalent var onToggle : (Bool) -> () - static var isEquivalentKeyPaths: KeyPaths { + static var isEquivalent: KeyPaths { \.content } diff --git a/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift b/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift index 9b933215d..aedae3da7 100644 --- a/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift +++ b/Demo/Sources/Demos/Demo Screens/PaymentTypesViewController.swift @@ -214,7 +214,7 @@ fileprivate struct PaymentTypeRow : BlueprintItemContent, KeyPathLayoutEquivalen ) } - static var isEquivalentKeyPaths: KeyPaths { + static var isEquivalent: KeyPaths { \.type } } diff --git a/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift b/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift index 6f48db672..dceb9d453 100644 --- a/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift +++ b/ListableUI/Sources/IsEquivalent/KeyPathLayoutEquivalent.swift @@ -30,7 +30,7 @@ public protocol KeyPathLayoutEquivalent : LayoutEquivalent { /// any cached sizing it has stored for the content, and re-measure + re-layout the content. /// /// ## ⚠️ Important - /// `isEquivalentKeyPaths` is **not** an identifier check. That is what the `identifierValue` + /// `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 @@ -44,7 +44,7 @@ public protocol KeyPathLayoutEquivalent : LayoutEquivalent { /// var theme : MyTheme /// var onTapDetail : () -> () /// - /// static var isEquivalentKeyPaths : KeyPaths { + /// static var isEquivalent : KeyPaths { /// // 🚫 Missing checks for title and detail. /// // If they change, they likely affect sizing, /// // which would result in incorrect item sizing. @@ -52,7 +52,7 @@ public protocol KeyPathLayoutEquivalent : LayoutEquivalent { /// \.theme /// } /// - /// static var isEquivalentKeyPaths : KeyPaths { + /// 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, @@ -63,7 +63,7 @@ public protocol KeyPathLayoutEquivalent : LayoutEquivalent { /// \.detail /// } /// - /// static var isEquivalentKeyPaths : KeyPaths { + /// static var isEquivalent : KeyPaths { /// // ✅ Checking all parameters which can affect appearance + layout. /// // 💡 Not checking identifierValue or onTapDetail, since they /// // do not affect appearance + layout. @@ -84,7 +84,7 @@ public protocol KeyPathLayoutEquivalent : LayoutEquivalent { /// If your ``ItemContent`` conforms to ``Equatable``, there is a default /// implementation of this method which simply returns `self == other`. /// - @Builder static var isEquivalentKeyPaths : KeyPaths { get } + @Builder static var isEquivalent : KeyPaths { get } } @@ -92,7 +92,7 @@ fileprivate var cachedIsEquivalentKeyPaths : [ObjectIdentifier:Any] = [:] extension KeyPathLayoutEquivalent { - /// Implements `isEquivalent(to:)` based on `isEquivalentKeyPaths`. + /// Implements `isEquivalent(to:)` based on `isEquivalent`. public func isEquivalent(to other: Self) -> Bool { let keyPaths : KeyPaths = { @@ -101,7 +101,7 @@ extension KeyPathLayoutEquivalent { if let existing = cachedIsEquivalentKeyPaths[id] { return existing as! KeyPaths } else { - let new = Self.isEquivalentKeyPaths + let new = Self.isEquivalent cachedIsEquivalentKeyPaths[id] = new return new } diff --git a/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift b/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift index 2ea182da6..85d14c90d 100644 --- a/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift +++ b/ListableUI/Tests/KeyPathLayoutEquivalentTests.swift @@ -12,19 +12,19 @@ class KeyPathLayoutEquivalentTests : XCTestCase { func test_isEquivalent() { - struct TestingThing : KeyPathLayoutEquivalent { - - var name : String - var age : Int - var birthdate : Date - var nonCompared : Bool - - static var isEquivalentKeyPaths: KeyPaths { - \.name - \.age - \.birthdate - } - } +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", @@ -50,7 +50,7 @@ class KeyPathLayoutEquivalentTests : XCTestCase { XCTAssertTrue(value1.isEquivalent(to: equivalentToValue1)) XCTAssertFalse(value1.isEquivalent(to: notEquivalentToValue1)) - /// Our implementation caches the result of `isEquivalentKeyPaths`, + /// 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)) From d00c5346aed6c81dac6055a61cb8c7c8d0be0527 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 15 Jun 2023 15:58:08 -0700 Subject: [PATCH 38/38] Split test case into two --- BlueprintUILists/Tests/ListableBuilder+ElementTests.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift index f7b7539b3..981359cef 100644 --- a/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift +++ b/BlueprintUILists/Tests/ListableBuilder+ElementTests.swift @@ -9,7 +9,7 @@ import BlueprintUILists import XCTest -class ListableBuilder_Element_Tests : XCTestCase { +class ListableArrayBuilder_Element_Tests : XCTestCase { func test_builders() { @@ -79,7 +79,11 @@ class ListableBuilder_Element_Tests : XCTestCase { 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