diff --git a/ListableUI/Sources/Decoration/AnyDecoration.swift b/ListableUI/Sources/Decoration/AnyDecoration.swift new file mode 100644 index 000000000..efa6b4689 --- /dev/null +++ b/ListableUI/Sources/Decoration/AnyDecoration.swift @@ -0,0 +1,39 @@ +// +// AnyDecoration.swift +// ListableUI +// +// Created by Goose on 7/24/25. +// + +import Foundation +import UIKit + + +public protocol AnyDecoration : AnyDecorationConvertible, AnyDecoration_Internal +{ + var anyContent : Any { get } + + var sizing : Sizing { get set } + var layouts : DecorationLayouts { get set } + + var reappliesToVisibleView: ReappliesToVisibleView { get } +} + + +public protocol AnyDecoration_Internal +{ + var layouts : DecorationLayouts { get } + + func apply( + to decorationView : UIView, + for reason : ApplyReason, + with info : ApplyDecorationContentInfo + ) + + func anyIsEquivalent(to other : AnyDecoration) -> Bool + + func newPresentationDecorationState( + kind : SupplementaryKind, + performsContentCallbacks : Bool + ) -> Any +} diff --git a/ListableUI/Sources/Decoration/AnyDecorationConvertible.swift b/ListableUI/Sources/Decoration/AnyDecorationConvertible.swift new file mode 100644 index 000000000..fe7fda7ad --- /dev/null +++ b/ListableUI/Sources/Decoration/AnyDecorationConvertible.swift @@ -0,0 +1,39 @@ +// +// AnyDecorationConvertible.swift +// ListableUI +// +// Created by Goose on 7/24/25. +// + +import Foundation + + +/// A type which can be converted into a `Decoration`, so you +/// do not need to explicitly wrap / convert your `DecorationContent` +/// in a `Decoration` when providing a decoration to a list or section: +/// +/// ``` +/// Section("id") { section in +/// section.decoration = MyDecorationContent(backgroundColor: .red) +/// } +/// +/// struct MyDecorationContent : DecorationContent { +/// var backgroundColor : UIColor +/// ... +/// } +/// ``` +/// +/// Only two types conform to this protocol: +/// +/// ### `Decoration` +/// The `Decoration` conformance simply returns self. +/// +/// ### `DecorationContent` +/// The `DecorationContent` conformance returns `Decoration(self)`, +/// utilizing the default values from the `Decoration` initializer. +/// +public protocol AnyDecorationConvertible { + + /// Converts the object into a type-erased `AnyDecoration` instance. + func asAnyDecoration() -> AnyDecoration +} diff --git a/ListableUI/Sources/Decoration/Decoration.swift b/ListableUI/Sources/Decoration/Decoration.swift new file mode 100644 index 000000000..4da062c3a --- /dev/null +++ b/ListableUI/Sources/Decoration/Decoration.swift @@ -0,0 +1,168 @@ +// +// Decoration.swift +// ListableUI +// +// Created by Goose on 7/24/25. +// + +import UIKit + + +public struct Decoration : AnyDecoration +{ + public var content : Content + + public var sizing : Sizing + public var layouts : DecorationLayouts + + public typealias OnTap = () -> () + public var onTap : OnTap? + + public var onDisplay : OnDisplay.Callback? + public var onEndDisplay : OnEndDisplay.Callback? + + public var debuggingIdentifier : String? = nil + + internal let reuseIdentifier : ReuseIdentifier + + // + // MARK: Initialization + // + + public typealias Configure = (inout Decoration) -> () + + public init( + _ content : Content, + configure : Configure + ) { + self.init(content) + + configure(&self) + } + + public init( + _ content : Content, + sizing : Sizing? = nil, + layouts : DecorationLayouts? = nil, + onTap : OnTap? = nil, + onDisplay : OnDisplay.Callback? = nil, + onEndDisplay : OnEndDisplay.Callback? = nil + ) { + assertIsValueType(Content.self) + + self.content = content + + let defaults = self.content.defaultDecorationProperties + + self.sizing = sizing ?? defaults.sizing ?? .thatFits(.noConstraint) + self.layouts = layouts ?? defaults.layouts ?? .init() + self.onTap = onTap ?? defaults.onTap + self.onDisplay = onDisplay + self.onEndDisplay = onEndDisplay + self.debuggingIdentifier = defaults.debuggingIdentifier + + self.reuseIdentifier = ReuseIdentifier.identifier(for: Content.self) + } + + // MARK: AnyDecoration + + public var anyContent: Any { + self.content + } + + public var reappliesToVisibleView: ReappliesToVisibleView { + self.content.reappliesToVisibleView + } + + // MARK: AnyDecorationConvertible + + public func asAnyDecoration() -> AnyDecoration { + self + } + + // MARK: AnyDecoration_Internal + + public func apply( + to anyView : UIView, + for reason : ApplyReason, + with info : ApplyDecorationContentInfo + ) { + let view = anyView as! DecorationContentView + + let views = DecorationContentViews(view: view) + + self.content.apply( + to: views, + for: reason, + with: info + ) + } + + public func anyIsEquivalent(to other : AnyDecoration) -> Bool + { + guard let other = other as? Decoration else { + return false + } + + return self.content.isEquivalent(to: other.content) + } + + public func newPresentationDecorationState( + kind : SupplementaryKind, + performsContentCallbacks : Bool + ) -> Any + { + return PresentationState.DecorationState( + self, + kind: kind, + performsContentCallbacks: performsContentCallbacks + ) + } +} + + +extension DecorationContent { + + /// Identical to `Decoration.init` which takes in a `DecorationContent`, + /// except you can call this on the `DecorationContent` itself, instead of wrapping it, + /// to avoid additional nesting, and to hoist your content up in your code. + /// + /// ``` + /// Section("id") { section in + /// section.decoration = MyDecorationContent( + /// backgroundColor: .red + /// ) + /// .with( + /// sizing: .thatFits(.noConstraint), + /// ) + /// + /// struct MyDecorationContent : DecorationContent { + /// var backgroundColor : UIColor + /// ... + /// } + /// ``` + public func with( + sizing : Sizing? = nil, + layouts : DecorationLayouts? = nil, + onTap : Decoration.OnTap? = nil + ) -> Decoration + { + Decoration( + self, + sizing: sizing, + layouts: layouts, + onTap: onTap + ) + } +} + + +extension Decoration : SignpostLoggable +{ + var signpostInfo : SignpostLoggingInfo { + SignpostLoggingInfo( + identifier: self.debuggingIdentifier, + instanceIdentifier: nil + ) + } +} diff --git a/ListableUI/Sources/Decoration/DecorationCallbacks.swift b/ListableUI/Sources/Decoration/DecorationCallbacks.swift new file mode 100644 index 000000000..abd388b8f --- /dev/null +++ b/ListableUI/Sources/Decoration/DecorationCallbacks.swift @@ -0,0 +1,33 @@ +// +// HeaderFooterCallbacks.swift +// ListableUI +// +// Created by Kyle Van Essen on 4/3/25. +// + +import Foundation + + +extension Decoration { + + /// Value passed to the `onDisplay` callback for `HeaderFooter`. + public struct OnDisplay + { + public typealias Callback = (OnDisplay) -> () + + public var decoration : Decoration + + public var isFirstDisplay : Bool + } + + /// Value passed to the `onEndDisplay` callback for `HeaderFooter`. + public struct OnEndDisplay + { + public typealias Callback = (OnEndDisplay) -> () + + public var decoration : Decoration + + public var isFirstEndDisplay : Bool + } +} + diff --git a/ListableUI/Sources/Decoration/DecorationContent.swift b/ListableUI/Sources/Decoration/DecorationContent.swift new file mode 100644 index 000000000..669d701f5 --- /dev/null +++ b/ListableUI/Sources/Decoration/DecorationContent.swift @@ -0,0 +1,239 @@ +// +// DecorationContent.swift +// ListableUI +// +// Created by Goose on 7/24/25. +// + +import UIKit + +/// +/// A `DecorationContent` is a type which specifies the content of a decoration +/// view within a listable list. +/// +/// A non-tappable decoration that shows a background color might look like this (implementation of `MyDecorationView` left up to the reader): +/// ``` +/// struct MyDecorationContent : DecorationContent, Equatable +/// { +/// var backgroundColor : UIColor +/// +/// static func createReusableContentView(frame : CGRect) -> MyDecorationView { +/// MyDecorationView(frame: frame) +/// } +/// +/// func apply(to views : DecorationContentViews, reason : ApplyReason) { +/// views.content.backgroundColor = self.backgroundColor +/// } +/// } +/// ``` +/// The decoration is made `Equatable` in order to synthesize automatic conformance to `isEquivalent`, +/// based on the decoration's properties. +/// +/// If you want to add support for rendering a background view and a pressed state, you should provide +/// both `createReusableBackgroundView` and `createReusablePressedBackgroundView` methods, +/// and apply the desired content in your `apply(to:)` method. +/// +/// The ordering of the elements by z-index is as follows: +/// z-Index 3) `ContentView` +/// z-Index 2) `PressedBackgroundView` (Only if the decoration is pressed, eg if the wrapping `Decoration` has an `onTap` handler.) +/// z-Index 1) `BackgroundView` +/// +public protocol DecorationContent : AnyDecorationConvertible +{ + // + // MARK: Tracking Changes + // + + func isEquivalent(to other : Self) -> Bool + + // + // MARK: Default Properties + // + + typealias DefaultProperties = DefaultDecorationProperties + + /// Default values to assign to various properties on the `Decoration` which wraps + /// this `DecorationContent`, if those values are not passed to the `Decoration` initializer. + var defaultDecorationProperties : DefaultProperties { get } + + // + // MARK: Applying To Displayed View + // + + func apply( + to views : DecorationContentViews, + for reason : ApplyReason, + with info : ApplyDecorationContentInfo + ) + + /// When the `DecorationContent` is on screen, controls how and when to apply updates + /// to the view. + /// + /// Defaults to ``ReappliesToVisibleView/always``. + /// + /// See ``ReappliesToVisibleView`` for a full discussion. + var reappliesToVisibleView: ReappliesToVisibleView { get } + + // + // MARK: Creating & Providing Content Views + // + + /// The content view used to draw the content. + /// The content view is drawn at the top of the view hierarchy, above the background views. + associatedtype ContentView:UIView + + /// Create and return a new content view used to render the content. + /// + /// 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 createReusableContentView(frame : CGRect) -> ContentView + + // + // MARK: Creating & Providing Background Views + // + + /// The background view used to draw the background of the content. + /// The background view is drawn below the content view. + /// + /// 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. + /// + associatedtype BackgroundView:UIView = UIView + + /// Create and return a new background view used to render the content's background. + /// + /// 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 + + /// 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 + /// ---- + /// 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. + /// + associatedtype PressedBackgroundView:UIView = UIView + + /// Create and return a new background view used to render the content's pressed background. + /// + /// This view is displayed when the user taps/presses the decoration. + /// + /// If your `BackgroundView` and `SelectedBackgroundView` are the same type, this method + /// is provided automatically by calling `createReusableBackgroundView`. + /// + /// 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 createReusablePressedBackgroundView(frame : CGRect) -> PressedBackgroundView +} + + +/// Information about the current state of the content, which is passed to `apply(to:for:with:)` +/// during configuration and preparation for display. +/// +/// TODO: Rename to `ApplyDecorationContext` +public struct ApplyDecorationContentInfo +{ + /// The environment of the containing list. + /// See `ListEnvironment` for usage information. + public var environment : ListEnvironment +} + + +/// The views owned by the item content, passed to the `apply(to:) method to theme and provide content.` +public struct DecorationContentViews +{ + let view : DecorationContentView + + /// The content view of the content. + public var content : Content.ContentView { + view.content + } + + /// The background view of the content. + public var background : Content.BackgroundView { + view.background + } + + /// The background view of the content, if it has been used. + public var backgroundIfLoaded : Content.BackgroundView? { + view.backgroundIfLoaded + } + + /// The background view of the content that's displayed while a press is active. + public var pressedBackground : Content.PressedBackgroundView { + view.pressedBackground + } + + /// The background view of the content that's displayed while a press is active, if it has been used. + public var pressedBackgroundIfLoaded : Content.PressedBackgroundView? { + view.pressedBackgroundIfLoaded + } +} + + +/// Provide a default implementation of `reappliesToVisibleView` which returns `.always`. +public extension DecorationContent { + + var reappliesToVisibleView: ReappliesToVisibleView { + .always + } +} + + +public extension DecorationContent { + + // MARK: AnyDecorationConvertible + + func asAnyDecoration() -> AnyDecoration { + Decoration(self) + } +} + + +public extension DecorationContent where Self:Equatable +{ + /// If your `DecorationContent` is `Equatable`, `isEquivalent` is based on the `Equatable` implementation. + func isEquivalent(to other : Self) -> Bool { + self == other + } +} + + +public extension DecorationContent where Self.BackgroundView == UIView +{ + static func createReusableBackgroundView(frame : CGRect) -> BackgroundView + { + BackgroundView(frame: frame) + } +} + + +public extension DecorationContent where Self.PressedBackgroundView == UIView +{ + static func createReusablePressedBackgroundView(frame : CGRect) -> PressedBackgroundView + { + PressedBackgroundView(frame: frame) + } +} + + +/// Provide a default implementation of `defaultDecorationProperties` which returns an +/// empty instance that does not provide any defaults. +public extension DecorationContent +{ + var defaultDecorationProperties : DefaultProperties { + .init() + } +} diff --git a/ListableUI/Sources/Decoration/DecorationLayouts.swift b/ListableUI/Sources/Decoration/DecorationLayouts.swift new file mode 100644 index 000000000..36b5b18d1 --- /dev/null +++ b/ListableUI/Sources/Decoration/DecorationLayouts.swift @@ -0,0 +1,124 @@ +// +// DecorationLayouts.swift +// ListableUI +// +// Created by Goose on 7/24/25. +// + +import Foundation + + +/// +/// `DecorationLayouts` allows you to provide `ListLayout`-specific layout configuration for +/// individual decorations within a list. Eg, customize the layout for a decoration when it is in a table, a grid, etc. +/// +/// For example, if you want to specify a custom layout for table layouts, you +/// would do the following on your decoration: +/// +/// ``` +/// myDecoration.layouts.table = .init( +/// width: .fill +/// ) +/// ``` +/// +/// And then, when the `Decoration` is used within a `.table` style +/// list layout, the provided layout will be used. +/// +/// If you plan on swapping between multiple `ListLayout` types on your list, +/// you can provide multiple layouts. The correct one will be used at the correct time: +/// +/// ``` +/// myDecoration.layouts.table = .init( +/// width: .fill +/// ) +/// +/// myDecoration.layouts.otherLayout = .init( +/// width: 300, +/// alignment: .left +/// padding: 10 +/// ) +/// ``` +/// +/// Note +/// ---- +/// When implementing your own custom layout, you should add an extension to `DecorationLayouts`, +/// to provide easier access to your layout-specific `DecorationLayoutsValue` type, like so: +/// +/// ``` +/// extension DecorationLayouts { +/// public var table : TableAppearance.Decoration.Layout { +/// get { self[TableAppearance.Decoration.Layout.self] } +/// set { self[TableAppearance.Decoration.Layout.self] = newValue } +/// } +/// } +/// ``` +public struct DecorationLayouts { + + /// Creates a new instance of the layouts, with an optional `configure` + /// closure, to allow you to set up styling inline. + public init( + _ configure : (inout Self) -> () = { _ in } + ) { + self.storage = .init() + + configure(&self) + } + + private var storage : ContentLayoutsStorage + + /// Allows accessing the various `DecorationLayoutValue`s stored within the object. + /// This method will return the `defaultValue` for a value if none is set. + /// + /// ### Note + /// When implementing your own custom layout, you should add an extension to `DecorationLayouts`, + /// to provide easier access to your layout-specific `DecorationLayoutsValue` type. + /// + /// ``` + /// extension DecorationLayouts { + /// public var table : TableAppearance.Decoration.Layout { + /// get { self[TableAppearance.Decoration.Layout.self] } + /// set { self[TableAppearance.Decoration.Layout.self] = newValue } + /// } + /// } + /// ``` + public subscript(_ valueType : ValueType.Type) -> ValueType { + get { self.storage.get(valueType, default: ValueType.defaultValue) } + set { self.storage.set(valueType, new: newValue) } + } +} + + +/// +/// The `DecorationLayoutsValue` protocol provides a default value for the different layouts stored +/// within `DecorationLayouts`. Provide a `defaultValue` with reasonable defaults, as the +/// developer should not need to set these values at all times when using your layout. +/// +/// ``` +/// public struct Layout : Equatable, DecorationLayoutsValue +/// { +/// public var width : CGFloat +/// public var minHeight : CGFloat +/// +/// ... +/// +/// public static var defaultValue : Self { +/// ... +/// } +/// } +/// ``` +public protocol DecorationLayoutsValue { + + /// The default value used when accessing the value, if none is set. + static var defaultValue : Self { get } +} + + +/// Use this type if you have no `DecorationLayout` for your `ListLayout`. +public struct EmptyDecorationLayoutsValue : DecorationLayoutsValue { + + public init() {} + + public static var defaultValue: EmptyDecorationLayoutsValue { + .init() + } +} diff --git a/ListableUI/Sources/Decoration/DefaultDecorationProperties.swift b/ListableUI/Sources/Decoration/DefaultDecorationProperties.swift new file mode 100644 index 000000000..1fcf0b47b --- /dev/null +++ b/ListableUI/Sources/Decoration/DefaultDecorationProperties.swift @@ -0,0 +1,48 @@ +// +// DefaultDecorationProperties.swift +// ListableUI +// +// Created by Goose on 7/24/25. +// + +import Foundation + + +/// Allows specifying default properties to apply to a decoration when it is initialized, +/// if those values are not provided to the initializer. +/// Only non-nil values are used – if you do not want to provide a default value, +/// simply leave the property nil. +/// +/// The order of precedence used when assigning values is: +/// 1) The value passed to the initializer. +/// 2) The value from `defaultDecorationProperties` on the contained `DecorationContent`, if non-nil. +/// 3) A standard, default value. +public struct DefaultDecorationProperties +{ + public typealias Decoration = ListableUI.Decoration + + public var sizing : Sizing? + public var layouts : DecorationLayouts? + public var onTap : Decoration.OnTap? + public var debuggingIdentifier : String? + + public init( + sizing : Sizing? = nil, + layouts : DecorationLayouts? = nil, + onTap : Decoration.OnTap? = nil, + debuggingIdentifier : String? = nil, + + configure : (inout Self) -> () = { _ in } + ) { + self.sizing = sizing + self.layouts = layouts + self.onTap = onTap + self.debuggingIdentifier = debuggingIdentifier + + configure(&self) + } + + public static func defaults(with configure : (inout Self) -> () = { _ in }) -> Self { + .init(configure: configure) + } +} diff --git a/ListableUI/Sources/Internal/DecorationContentView.swift b/ListableUI/Sources/Internal/DecorationContentView.swift new file mode 100644 index 000000000..b4c867312 --- /dev/null +++ b/ListableUI/Sources/Internal/DecorationContentView.swift @@ -0,0 +1,212 @@ +// +// DecorationContentView.swift +// ListableUI +// +// Created by Goose on 7/24/25. +// + +import UIKit + + +final class DecorationContentView : UIView +{ + // + // MARK: Properties + // + + typealias OnTap = () -> () + + var onTap : OnTap? = nil { + didSet { self.updateIsTappable() } + } + + let content : Content.ContentView + + private(set) lazy var background : Content.BackgroundView = { + + let background = Content.createReusableBackgroundView(frame: bounds) + + self.insertSubview(background, belowSubview: self.content) + + self.backgroundIfLoaded = background + + updateIsTappable() + + return background + }() + + private(set) var backgroundIfLoaded : Content.BackgroundView? + + private(set) lazy var pressedBackground : Content.PressedBackgroundView = { + + let background = Content.createReusablePressedBackgroundView(frame: bounds) + + /// Loads the background so subviews are inserted in the proper order. + self.insertSubview(background, aboveSubview: self.background) + + self.pressedBackgroundIfLoaded = background + + updateIsTappable() + + return background + }() + + private(set) var pressedBackgroundIfLoaded : Content.PressedBackgroundView? + + private let pressRecognizer : PressGestureRecognizer + + // + // MARK: Initialization + // + + override init(frame: CGRect) { + + let bounds = CGRect(origin: .zero, size: frame.size) + + self.content = Content.createReusableContentView(frame: bounds) + + self.pressRecognizer = PressGestureRecognizer() + self.pressRecognizer.minimumPressDuration = 0.0 + self.pressRecognizer.allowableMovementAfterBegin = 5.0 + + super.init(frame: frame) + + self.pressRecognizer.addTarget(self, action: #selector(pressStateChanged)) + + self.addSubview(self.content) + + self.updateIsTappable() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // + // MARK: UIView + // + + override func sizeThatFits(_ size: CGSize) -> CGSize { + self.content.sizeThatFits(size) + } + + override func systemLayoutSizeFitting(_ targetSize: CGSize) -> CGSize { + self.content.systemLayoutSizeFitting(targetSize) + } + + override func systemLayoutSizeFitting( + _ targetSize: CGSize, + withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, + verticalFittingPriority: UILayoutPriority + ) -> CGSize { + self.content.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: horizontalFittingPriority, + verticalFittingPriority: verticalFittingPriority + ) + } + + override func layoutSubviews() { + super.layoutSubviews() + + self.content.frame = self.bounds + + self.backgroundIfLoaded?.frame = self.bounds + self.pressedBackgroundIfLoaded?.frame = self.bounds + } + + // + // MARK: Tap Handling + // + + private func updateIsTappable() + { + self.removeGestureRecognizer(self.pressRecognizer) + + if self.onTap != nil { + self.accessibilityTraits = [.button] + + self.pressedBackgroundIfLoaded?.isHidden = false + self.pressedBackgroundIfLoaded?.alpha = 0.0 + + self.addGestureRecognizer(self.pressRecognizer) + } else { + self.accessibilityTraits = [] + + self.pressedBackgroundIfLoaded?.isHidden = true + } + } + + @objc private func pressStateChanged() { + + let state = self.pressRecognizer.state + + switch state { + + case .possible: + break + + case .began, .changed: + self.pressedBackgroundIfLoaded?.alpha = 1.0 + + case .ended, .cancelled, .failed: + let didEnd = state == .ended + + UIView.animate(withDuration: didEnd ? 0.1 : 0.0) { + self.pressedBackgroundIfLoaded?.alpha = 0.0 + } + + if didEnd { + self.onTap?() + } + + @unknown default: break + } + } +} + + +// TODO: Dedupe with header footer content view +fileprivate final class PressGestureRecognizer : UILongPressGestureRecognizer { + + var allowableMovementAfterBegin : CGFloat = 0.0 + + private var initialPoint : CGPoint? = nil + + override func reset() { + super.reset() + + self.initialPoint = nil + } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + super.touchesBegan(touches, with: event) + + self.initialPoint = self.location(in: self.view) + } + + override func canPrevent(_ gesture: UIGestureRecognizer) -> Bool { + + // We want to allow the pan gesture of our containing scroll view to continue to track + // when the user moves their finger vertically or horizontally, when we are cancelled. + + if let panGesture = gesture as? UIPanGestureRecognizer, panGesture.view is UIScrollView { + return false + } + + return super.canPrevent(gesture) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent) { + super.touchesMoved(touches, with: event) + + if let initialPoint = self.initialPoint { + let currentPoint = self.location(in: self.view) + + let distance = sqrt(pow(abs(initialPoint.x - currentPoint.x), 2) + pow(abs(initialPoint.y - currentPoint.y), 2)) + + if distance > self.allowableMovementAfterBegin { + self.state = .failed + } + } + } +} diff --git a/ListableUI/Sources/Internal/PresentationState/PresentationState.DecorationState.swift b/ListableUI/Sources/Internal/PresentationState/PresentationState.DecorationState.swift new file mode 100644 index 000000000..ffd9ed9b6 --- /dev/null +++ b/ListableUI/Sources/Internal/PresentationState/PresentationState.DecorationState.swift @@ -0,0 +1,327 @@ +// +// PresentationState.DecorationState.swift +// ListableUI +// +// Created by Goose on 7/24/25. +// + +import Foundation +import UIKit + + +protocol AnyPresentationDecorationState : AnyObject +{ + var isDisplayed : Bool { get } + func setAndPerform(isDisplayed: Bool) + + var anyModel : AnyDecoration { get } + + var kind : SupplementaryKind { get } + + var oldIndexPath : IndexPath? { get } + + var containsFirstResponder : Bool { get set } + + func updateOldIndexPath(in section : Int) + + func dequeueAndPrepareReusableDecorationView( + in cache : ReusableViewCache, + frame : CGRect, + environment : ListEnvironment + ) -> UIView + + func enqueueReusableDecorationView(_ view : UIView, in cache : ReusableViewCache) + + func applyTo( + view : UIView, + for reason : ApplyReason, + with info : ApplyDecorationContentInfo + ) + + func set( + new : AnyDecoration, + reason : ApplyReason, + visibleView : UIView?, + updateCallbacks : UpdateCallbacks, + info : ApplyDecorationContentInfo + ) + + func resetCachedSizes() + + func size( + for info : Sizing.MeasureInfo, + cache : ReusableViewCache, + environment : ListEnvironment + ) -> CGSize +} + + +extension PresentationState +{ + final class DecorationViewStatePair + { + private(set) var state : AnyPresentationDecorationState? + + private(set) var visibleContainer : SupplementaryContainerView? + + init(state : AnyPresentationDecorationState?) { + self.state = state + } + + func update( + with state : AnyPresentationDecorationState?, + new: AnyDecorationConvertible?, + reason: ApplyReason, + animated : Bool, + updateCallbacks : UpdateCallbacks, + environment: ListEnvironment + ) { + self.visibleContainer?.environment = environment + + if self.state !== state { + self.state = state + fatalError("TODO") + //self.visibleContainer?.setDecoration(state, animated: reason.shouldAnimate && animated) + } else { + if let state = state, let new = new { + state.set( + new: new.asAnyDecoration(), + reason: reason, + visibleView: self.visibleContainer?.content, + updateCallbacks: updateCallbacks, + info: .init(environment: environment) + ) + } + } + } + + func collectionViewWillDisplay(view : SupplementaryContainerView) + { + /// **Note**: It's possible for this method and the below + /// to be called in an unbalanced manner (eg, we get moved to a new supplementary view), + /// _without_ an associated call to `collectionViewDidEndDisplay(of:)`. + /// + /// Thus, if any logic added to this method depends on the instance + /// of `visibleContainer` changing, wrap it in a `===` check. + + self.visibleContainer = view + } + + func collectionViewDidEndDisplay(of view : SupplementaryContainerView) + { + /// **Note**: This method is called _after_ the animation that removes + /// supplementary views from the collection view, so the ordering can be: + /// + /// 1) `collectionViewWillDisplay` of new supplementary view + /// 2) We're moved to that new supplementary view. + /// 2) Collection view finishes animation + /// 3) `collectionViewDidEndDisplay` is called. + /// + /// Because we manage the `Decoration` view instances ourselves, + /// and simply insert them into a whatever supplementary view the collection view + /// is currently vending us, it's possible that `collectionViewWillDisplay` + /// has already assigned us a new supplementary view. Make sure the one + /// we're being asked to remove is the one we know about, otherwise, do nothing. + + guard view === visibleContainer else { + return + } + + self.visibleContainer = nil + } + + func updateOldIndexPath(in section : Int) { + state?.updateOldIndexPath(in: section) + } + } + + + final class DecorationState : AnyPresentationDecorationState + { + var model : Decoration + + let performsContentCallbacks : Bool + + init( + _ model : Decoration, + kind: SupplementaryKind, + performsContentCallbacks : Bool + ) + { + self.model = model + self.kind = kind + self.performsContentCallbacks = performsContentCallbacks + } + + // MARK: AnyPresentationDecorationState + + private(set) var isDisplayed : Bool = false + + private var hasDisplayed : Bool = false + private var hasEndedDisplay : Bool = false + + func setAndPerform(isDisplayed: Bool) { + guard self.isDisplayed != isDisplayed else { + return + } + + self.isDisplayed = isDisplayed + + if self.isDisplayed { + if self.performsContentCallbacks { + self.model.onDisplay?(.init( + decoration: self.model, + isFirstDisplay: self.hasDisplayed == false + ) + ) + } + + self.hasDisplayed = true + } else { + if self.performsContentCallbacks { + self.model.onEndDisplay?(.init( + decoration: self.model, + isFirstEndDisplay: self.hasEndedDisplay == false + ) + ) + } + + self.hasEndedDisplay = true + } + } + + var anyModel: AnyDecoration { + return self.model + } + + private(set) var kind : SupplementaryKind + + var oldIndexPath : IndexPath? = nil + + var containsFirstResponder : Bool = false + + func updateOldIndexPath(in section : Int) { + oldIndexPath = kind.indexPath(in: section) + } + + func dequeueAndPrepareReusableDecorationView( + in cache : ReusableViewCache, + frame : CGRect, + environment : ListEnvironment + ) -> UIView + { + let view = cache.pop(with: self.model.reuseIdentifier) { + DecorationContentView(frame: frame) + } + + self.applyTo( + view: view, + for: .willDisplay, + with: .init(environment: environment) + ) + + return view + } + + func enqueueReusableDecorationView(_ view : UIView, in cache : ReusableViewCache) + { + cache.push(view, with: self.model.reuseIdentifier) + } + + func applyTo( + view : UIView, + for reason : ApplyReason, + with info : ApplyDecorationContentInfo + ) { + let view = view as! DecorationContentView + + let views = DecorationContentViews(view: view) + + view.onTap = self.model.onTap + + self.model.content.apply(to: views, for: reason, with: info) + } + + func set( + new : AnyDecoration, + reason : ApplyReason, + visibleView : UIView?, + updateCallbacks : UpdateCallbacks, + info : ApplyDecorationContentInfo + ) { + let old = self.model + + self.model = new as! Decoration + + let isEquivalent = self.model.anyIsEquivalent(to: old) + + let wantsReapplication = self.model.reappliesToVisibleView.shouldReapply( + comparing: old.reappliesToVisibleView, + isEquivalent: isEquivalent + ) + + if isEquivalent == false { + self.resetCachedSizes() + } + + if let view = visibleView, wantsReapplication { + updateCallbacks.performAnimation { + self.applyTo(view: view, for: reason, with: info) + } + } + } + + private var cachedSizes : [SizeKey:CGSize] = [:] + + func resetCachedSizes() + { + self.cachedSizes.removeAll() + } + + func size( + for info : Sizing.MeasureInfo, + cache : ReusableViewCache, + environment : ListEnvironment + ) -> CGSize + { + guard info.sizeConstraint.isEmpty == false else { + return .zero + } + + let key = SizeKey( + width: info.sizeConstraint.width, + height: info.sizeConstraint.height, + layoutDirection: info.direction, + sizing: self.model.sizing + ) + + if let size = self.cachedSizes[key] { + return size + } else { + SignpostLogger.log(.begin, log: .updateContent, name: "Measure Decoration", for: self.model) + + let size : CGSize = cache.use( + with: self.model.reuseIdentifier, + create: { + return DecorationContentView(frame: .zero) + }, { view in + let views = DecorationContentViews(view: view) + + self.model.content.apply( + to: views, + for: .measurement, + with: .init(environment: environment) + ) + + return self.model.sizing.measure(with: view, info: info) + }) + + self.cachedSizes[key] = size + + SignpostLogger.log(.end, log: .updateContent, name: "Measure Decoration", for: self.model) + + return size + } + } + } +} diff --git a/ListableUI/Sources/Layout/CollectionViewLayout.swift b/ListableUI/Sources/Layout/CollectionViewLayout.swift index af9212234..398e0568a 100644 --- a/ListableUI/Sources/Layout/CollectionViewLayout.swift +++ b/ListableUI/Sources/Layout/CollectionViewLayout.swift @@ -503,10 +503,21 @@ final class CollectionViewLayout : UICollectionViewLayout { return self.layout.content.contentSize } + + final class TestDecoration : UICollectionReusableView { + + override init(frame: CGRect) { + super.init(frame: frame) + + self.backgroundColor = .red + } + + required init?(coder: NSCoder) {fatalError() } + } override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { - return self.layout.content.layoutAttributes(in: rect, alwaysIncludeOverscroll: true) + self.layout.content.layoutAttributes(in: rect, alwaysIncludeOverscroll: true) } func visibleLayoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? @@ -523,6 +534,32 @@ final class CollectionViewLayout : UICollectionViewLayout { return self.layout.content.supplementaryLayoutAttributes(of: elementKind, at: indexPath) } + + // + // MARK: UICollectionViewLayout Methods: Decoration Views + // + + override func layoutAttributesForDecorationView( + ofKind elementKind: String, + at indexPath: IndexPath + ) -> UICollectionViewLayoutAttributes? + { + fatalError() + } + + override func initialLayoutAttributesForAppearingDecorationElement( + ofKind elementKind: String, + at decorationIndexPath: IndexPath + ) -> UICollectionViewLayoutAttributes? { + fatalError() + } + + override func finalLayoutAttributesForDisappearingDecorationElement( + ofKind elementKind: String, + at decorationIndexPath: IndexPath + ) -> UICollectionViewLayoutAttributes? { + fatalError() + } // // MARK: UICollectionViewLayout Methods: Insertions & Removals diff --git a/ListableUI/Sources/Layout/DecorationKind.swift b/ListableUI/Sources/Layout/DecorationKind.swift new file mode 100644 index 000000000..872690f5f --- /dev/null +++ b/ListableUI/Sources/Layout/DecorationKind.swift @@ -0,0 +1,16 @@ +// +// DecorationKind.swift +// ListableUI +// +// Created by Kyle Van Essen on 7/24/25. +// + +import Foundation + + +public enum DecorationKind : Codable { + + case list + case section + case item +} diff --git a/ListableUI/Sources/Layout/ElementKind.swift b/ListableUI/Sources/Layout/ElementKind.swift new file mode 100644 index 000000000..a8f0499d3 --- /dev/null +++ b/ListableUI/Sources/Layout/ElementKind.swift @@ -0,0 +1,59 @@ +// +// ElementKind.swift +// ListableUI +// +// Created by Kyle Van Essen on 7/24/25. +// + +import Foundation + + +enum ElementKind : Equatable, Hashable, Codable { + + private static let prefix = "Listable" + + case supplementary(SupplementaryKind) + case decoration(DecorationKind) + + init?(_ string: String) throws { + + guard let next = string.stripPrefix(Self.prefix) else { + return nil + } + + self = try JSONDecoder() + .decode( + Self.self, + from: next.data(using: .utf8)! + ) + } + + static let encoder : JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + }() + + var stringValue: String { + Self.prefix + String( + data: try! Self.encoder.encode(self), + encoding: .utf8 + )! + } +} + +fileprivate extension String { + + func stripPrefix(_ prefix: String) -> String? { + + guard count > prefix.count else { + return nil + } + + guard hasPrefix(prefix) else { + return nil + } + + return String(self.suffix(count - prefix.count)) + } +} diff --git a/ListableUI/Sources/Layout/ListLayout/ListLayoutContent.swift b/ListableUI/Sources/Layout/ListLayout/ListLayoutContent.swift index 5a09d5530..efa748ebd 100644 --- a/ListableUI/Sources/Layout/ListLayout/ListLayoutContent.swift +++ b/ListableUI/Sources/Layout/ListLayout/ListLayoutContent.swift @@ -97,7 +97,13 @@ public final class ListLayoutContent { let section = self.sections[indexPath.section] - switch SupplementaryKind(rawValue: kind)! { + let elementKind = try! ElementKind(kind) + + guard case let .supplementary(kind) = elementKind else { + fatalError("im low key dead") + } + + switch kind { case .listContainerHeader: return self.containerHeader.layoutAttributes(with: indexPath) case .listHeader: return self.header.layoutAttributes(with: indexPath) case .listFooter: return self.footer.layoutAttributes(with: indexPath) @@ -473,7 +479,10 @@ extension ListLayoutContent func layoutAttributes(with indexPath : IndexPath) -> UICollectionViewLayoutAttributes { - let attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: self.kind.rawValue, with: indexPath) + let attributes = UICollectionViewLayoutAttributes( + forSupplementaryViewOfKind: ElementKind.supplementary(self.kind).stringValue, + with: indexPath + ) attributes.frame = self.visibleFrame attributes.zIndex = self.zIndex @@ -537,27 +546,89 @@ extension ListLayoutContent } } + public final class DecorationInfo : ListLayoutContentItem + { + let state : AnyPresentationItemState // TODO: Different + + var indexPath : IndexPath + + let kind : DecorationKind + let insertAndRemoveAnimations : ItemInsertAndRemoveAnimations + public let measurer : (Sizing.MeasureInfo) -> CGSize + + public var measuredSize : CGSize = .zero + + public var size : CGSize = .zero + + public var x : CGFloat = .zero + public var y : CGFloat = .zero + + public var zIndex : Int = 0 + + public var layouts : ItemLayouts { + self.state.anyModel.layouts // TODO: Fix + } + + public var frame : CGRect { + CGRect( + origin: CGPoint(x: self.x, y: self.y), + size: self.size + ) + } + + init( + state : AnyPresentationItemState, // TODO: Fix + kind : DecorationKind, + indexPath : IndexPath, + insertAndRemoveAnimations : ItemInsertAndRemoveAnimations, + measurer : @escaping (Sizing.MeasureInfo) -> CGSize + ) { + self.state = state + self.kind = kind + self.indexPath = indexPath + self.insertAndRemoveAnimations = insertAndRemoveAnimations + self.measurer = measurer + } + + func layoutAttributes(with indexPath : IndexPath) -> UICollectionViewLayoutAttributes + { + let attributes = UICollectionViewLayoutAttributes( + forDecorationViewOfKind: ElementKind.decoration(self.kind).stringValue, + with: indexPath + ) + + attributes.frame = self.frame + attributes.zIndex = self.zIndex + + return attributes + } + } + enum ContentItem { case item(ListLayoutContent.ItemInfo, UICollectionViewLayoutAttributes) case supplementary(ListLayoutContent.SupplementaryItemInfo, UICollectionViewLayoutAttributes) - public var collectionViewLayoutAttributes : UICollectionViewLayoutAttributes { + case decoration(ListLayoutContent.DecorationInfo, UICollectionViewLayoutAttributes) + + var collectionViewLayoutAttributes : UICollectionViewLayoutAttributes { switch self { case .item(_, let attributes): return attributes case .supplementary(_, let attributes): return attributes + case .decoration(_, let attributes): return attributes } } - public var indexPath : IndexPath { + var indexPath : IndexPath { self.collectionViewLayoutAttributes.indexPath } - public var defaultFrame : CGRect { + var defaultFrame : CGRect { switch self { case .item(let item, _): return item.frame case .supplementary(let supplementary, _): return supplementary.defaultFrame + case .decoration(let decoration, _): return decoration.frame } } diff --git a/ListableUI/Sources/Layout/SupplementaryKind.swift b/ListableUI/Sources/Layout/SupplementaryKind.swift index a673bcc24..6ca447d9b 100644 --- a/ListableUI/Sources/Layout/SupplementaryKind.swift +++ b/ListableUI/Sources/Layout/SupplementaryKind.swift @@ -8,16 +8,20 @@ import Foundation -public enum SupplementaryKind : String, CaseIterable +// TODO: Rename to HeaderFooterKind, since even Decorations are "supplementary" views? +public enum SupplementaryKind : CaseIterable, Codable { - case listContainerHeader = "Listable.ListContainerHeader" - case listHeader = "Listable.ListHeader" - case listFooter = "Listable.ListFooter" + case listContainerHeader - case sectionHeader = "Listable.SectionHeader" - case sectionFooter = "Listable.SectionFooter" + case listHeader + case listFooter - case overscrollFooter = "Listable.OverscrollFooter" + case sectionHeader + case sectionFooter + + + // TODO: Convert to a decoration view + case overscrollFooter func indexPath(in section : Int) -> IndexPath { diff --git a/ListableUI/Sources/ListView/ListView.DataSource.swift b/ListableUI/Sources/ListView/ListView.DataSource.swift index 2f767f303..b94c94425 100644 --- a/ListableUI/Sources/ListView/ListView.DataSource.swift +++ b/ListableUI/Sources/ListView/ListView.DataSource.swift @@ -53,12 +53,18 @@ internal extension ListView func collectionView( _ collectionView: UICollectionView, - viewForSupplementaryElementOfKind kind: String, + viewForSupplementaryElementOfKind kindString: String, at indexPath: IndexPath ) -> UICollectionReusableView { + let elementKind = try! ElementKind(kindString)! + + guard case .supplementary(let kind) = elementKind else { + fatalError("Got an unexpected kind: \(elementKind)") + } + let statePair : PresentationState.HeaderFooterViewStatePair = { - switch SupplementaryKind(rawValue: kind)! { + switch kind { case .listContainerHeader: return self.presentationState.containerHeader case .listHeader: return self.presentationState.header case .listFooter: return self.presentationState.footer @@ -112,7 +118,7 @@ internal extension ListView } else { return SupplementaryContainerView.dequeue( in: collectionView, - for: kind, + for: kindString, at: indexPath, reuseCache: self.headerFooterReuseCache, environment: self.view.environment diff --git a/ListableUI/Sources/ListView/ListView.Delegate.swift b/ListableUI/Sources/ListView/ListView.Delegate.swift index 4b50d90ff..8b5419467 100644 --- a/ListableUI/Sources/ListView/ListView.Delegate.swift +++ b/ListableUI/Sources/ListView/ListView.Delegate.swift @@ -171,17 +171,26 @@ extension ListView at indexPath: IndexPath ) { - let container = anyView as! SupplementaryContainerView - let kind = SupplementaryKind(rawValue: kindString)! + let elementKind = try! ElementKind(kindString) - let headerFooter = self.presentationState.headerFooter( - of: kind, - in: indexPath.section - ) - - headerFooter.collectionViewWillDisplay(view: container) - - self.displayedSupplementaryItems[ObjectIdentifier(container)] = headerFooter + switch elementKind { + case .supplementary(let kind): + + let container = anyView as! SupplementaryContainerView + + let headerFooter = self.presentationState.headerFooter( + of: kind, + in: indexPath.section + ) + + headerFooter.collectionViewWillDisplay(view: container) + + self.displayedSupplementaryItems[ObjectIdentifier(container)] = headerFooter + case .decoration(let decorationKind): + fatalError("// TODO") + case nil: + print("Displaying unknown view \(kindString)") + } } func collectionView( @@ -191,13 +200,15 @@ extension ListView at indexPath: IndexPath ) { - let container = anyView as! SupplementaryContainerView - - guard let headerFooter = self.displayedSupplementaryItems.removeValue(forKey: ObjectIdentifier(container)) else { - return + if let container = anyView as? SupplementaryContainerView { + guard let headerFooter = self.displayedSupplementaryItems.removeValue(forKey: ObjectIdentifier(container)) else { + return + } + + headerFooter.collectionViewDidEndDisplay(of: container) + } else { + print("End displaying unknown view \(kindString)") } - - headerFooter.collectionViewDidEndDisplay(of: container) } func collectionView( diff --git a/ListableUI/Sources/ListView/ListView.VisibleContent.swift b/ListableUI/Sources/ListView/ListView.VisibleContent.swift index 8be0d1cd4..b72b682ae 100644 --- a/ListableUI/Sources/ListView/ListView.VisibleContent.swift +++ b/ListableUI/Sources/ListView/ListView.VisibleContent.swift @@ -87,6 +87,10 @@ extension ListView var headerFooters : Set = [] for item in visibleAttributes { + + guard let kindString = item.representedElementKind else { continue } + guard let kind = try? ElementKind(kindString) else { continue } + switch item.representedElementCategory { case .cell: items.insert(Item( @@ -95,17 +99,26 @@ extension ListView )) case .supplementaryView: - let kind = SupplementaryKind(rawValue: item.representedElementKind!)! + guard case let .supplementary(supplementary) = kind else { + continue + } headerFooters.insert(HeaderFooter( - kind: kind, + kind: supplementary, indexPath: item.indexPath, - headerFooter: view.storage.presentationState.headerFooter(of: kind, in: item.indexPath.section) + headerFooter: view.storage.presentationState.headerFooter( + of: supplementary, + in: item.indexPath.section + ) )) - case .decorationView: fatalError() + case .decorationView: + fatalError() - @unknown default: assertionFailure("Unknown representedElementCategory type.") + @unknown default: + assertionFailure( + "Unknown representedElementCategory type `\(item.representedElementCategory)`." + ) } } diff --git a/ListableUI/Sources/ListView/ListView.swift b/ListableUI/Sources/ListView/ListView.swift index 9ddc05669..adfcf9ee8 100644 --- a/ListableUI/Sources/ListView/ListView.swift +++ b/ListableUI/Sources/ListView/ListView.swift @@ -102,7 +102,10 @@ public final class ListView : UIView // Register supplementary views. SupplementaryKind.allCases.forEach { - SupplementaryContainerView.register(in: self.collectionView, for: $0.rawValue) + SupplementaryContainerView.register( + in: self.collectionView, + for: ElementKind.supplementary($0).stringValue + ) } // Size and update views.