From 6bd9624977b151b2b64dc83feff30b4d8d647b4f Mon Sep 17 00:00:00 2001 From: Luke Zhao Date: Thu, 18 Jul 2024 05:44:17 +0800 Subject: [PATCH] Support any UIView (#46) * Support component on generic UIViews * rename engine to componentEngine * Make tappableview a uiview instead * Rename ComponentViewComponent to ViewWrapperComponent + more cleanup * Update example and tests to not use Component * componentView -> hostingView * Add migration guide and other small fixes * Move migration guides into documentations --- .../AsyncImage/AsyncImageViewController.swift | 2 +- .../Examples/Card/CardViewController.swift | 2 +- .../Examples/Card/CardViewController2.swift | 2 +- .../Examples/Card/CardViewController3.swift | 2 +- .../ComplexLayoutViewController.swift | 16 +- .../Components/IntroductionCard.swift | 5 +- .../GalleryViewController.swift | 2 +- .../Supporting Files/AppDelegate.swift | 2 +- .../ComponentViewController.swift | 4 +- .../Supporting Files/Modifiers.swift | 4 +- README.md | 2 +- .../Animators/TransformAnimator.swift | 14 +- .../Animators/WrapperAnimator.swift | 34 +-- .../View/ComponentViewComponent.swift | 31 --- .../Components/View/PrimaryMenu.swift | 23 +-- .../View/PrimaryMenuComponent.swift | 2 +- .../Components/View/TappableView.swift | 4 +- .../View/TappableViewComponent.swift | 2 +- .../View/ViewWrapperComponent.swift | 33 +++ .../ComponentDisplayableView.swift | 83 ++++---- .../Core/ComponentView/ComponentEngine.swift | 189 ++++++++++------- .../ComponentView/ComponentScrollView.swift | 65 ------ .../Core/ComponentView/ComponentView.swift | 29 ++- .../UIView+ComponentEngine.swift | 100 +++++++++ Sources/UIComponent/Core/Model/Animator.swift | 42 ++-- .../Model/Component/Component+Modifier.swift | 22 +- .../Core/Model/Environment/Environment.swift | 6 +- .../CurrentComponentViewEnvironmentKey.swift | 32 --- .../HostingViewEnvironmentKey.swift | 32 +++ .../RenderNode/RenderNode+Modifiers.swift | 14 +- .../Documentation.docc/Animation.md | 38 ++-- .../Documentation.docc/Architecture.md | 31 +-- .../Documentation.docc/ComponentBasics.md | 5 +- .../Documentation.docc/CustomComponent.md | 2 +- .../Documentation.docc/CustomView.md | 2 +- .../MigrationGuides/Version2MigrationGuide.md | 0 .../MigrationGuides/Version4MigrationGuide.md | 44 ++++ .../PerformanceOptimization.md | 12 +- .../Documentation.docc/StateManagement.md | 12 +- Tests/UIComponentTests/ReuseTest.swift | 194 +++++++++--------- Tests/UIComponentTests/UIComponentTests.swift | 114 +++++----- 41 files changed, 683 insertions(+), 571 deletions(-) delete mode 100644 Sources/UIComponent/Components/View/ComponentViewComponent.swift create mode 100644 Sources/UIComponent/Components/View/ViewWrapperComponent.swift delete mode 100644 Sources/UIComponent/Core/ComponentView/ComponentScrollView.swift create mode 100644 Sources/UIComponent/Core/ComponentView/UIView+ComponentEngine.swift delete mode 100644 Sources/UIComponent/Core/Model/Environment/EnvironmentKey/CurrentComponentViewEnvironmentKey.swift create mode 100644 Sources/UIComponent/Core/Model/Environment/EnvironmentKey/HostingViewEnvironmentKey.swift rename Version2MigrationGuide.md => Sources/UIComponent/Documentation.docc/MigrationGuides/Version2MigrationGuide.md (100%) create mode 100644 Sources/UIComponent/Documentation.docc/MigrationGuides/Version4MigrationGuide.md diff --git a/Examples/UIComponentExample/Examples/AsyncImage/AsyncImageViewController.swift b/Examples/UIComponentExample/Examples/AsyncImage/AsyncImageViewController.swift index f4f4080b..02cb7cef 100644 --- a/Examples/UIComponentExample/Examples/AsyncImage/AsyncImageViewController.swift +++ b/Examples/UIComponentExample/Examples/AsyncImage/AsyncImageViewController.swift @@ -77,7 +77,7 @@ class AsyncImageViewController: ComponentViewController { override func viewDidLoad() { super.viewDidLoad() - componentView.animator = TransformAnimator() + componentView.componentEngine.animator = TransformAnimator() title = "Async Image" } } diff --git a/Examples/UIComponentExample/Examples/Card/CardViewController.swift b/Examples/UIComponentExample/Examples/Card/CardViewController.swift index c4488c4a..3ca3f387 100644 --- a/Examples/UIComponentExample/Examples/Card/CardViewController.swift +++ b/Examples/UIComponentExample/Examples/Card/CardViewController.swift @@ -77,7 +77,7 @@ class CardViewController: ComponentViewController { override func viewDidLoad() { super.viewDidLoad() - componentView.animator = TransformAnimator() + componentView.componentEngine.animator = TransformAnimator() } override var component: any Component { diff --git a/Examples/UIComponentExample/Examples/Card/CardViewController2.swift b/Examples/UIComponentExample/Examples/Card/CardViewController2.swift index e95b1818..ba8629d4 100644 --- a/Examples/UIComponentExample/Examples/Card/CardViewController2.swift +++ b/Examples/UIComponentExample/Examples/Card/CardViewController2.swift @@ -13,7 +13,7 @@ class CardViewController2: ComponentViewController { override func viewDidLoad() { super.viewDidLoad() - componentView.animator = TransformAnimator() + componentView.componentEngine.animator = TransformAnimator() } override var component: any Component { diff --git a/Examples/UIComponentExample/Examples/Card/CardViewController3.swift b/Examples/UIComponentExample/Examples/Card/CardViewController3.swift index 59e6da27..e4718a6b 100644 --- a/Examples/UIComponentExample/Examples/Card/CardViewController3.swift +++ b/Examples/UIComponentExample/Examples/Card/CardViewController3.swift @@ -31,7 +31,7 @@ class CardViewController3: ComponentViewController { override func viewDidLoad() { super.viewDidLoad() - componentView.animator = TransformAnimator() + componentView.componentEngine.animator = TransformAnimator() } override var component: any Component { diff --git a/Examples/UIComponentExample/Examples/Complex layout/ComplexLayoutViewController.swift b/Examples/UIComponentExample/Examples/Complex layout/ComplexLayoutViewController.swift index ccc2c132..6351063e 100644 --- a/Examples/UIComponentExample/Examples/Complex layout/ComplexLayoutViewController.swift +++ b/Examples/UIComponentExample/Examples/Complex layout/ComplexLayoutViewController.swift @@ -112,7 +112,7 @@ class ComplexLayoutViewController: ComponentViewController { self.didTapAddTag() } } - .view().with(\.animator, TransformAnimator()) + .view().with(\.componentEngine.animator, TransformAnimator()) Separator() Text("Tags column") FlexColumn(spacing: 5) { @@ -136,7 +136,9 @@ class ComplexLayoutViewController: ComponentViewController { self.didTapAddTag() } } - .size(height: 130).view().with(\.animator, TransformAnimator()) + .size(height: 130) + .view() + .with(\.componentEngine.animator, TransformAnimator()) Text("Shuffle tags").textColor(.systemBlue) .tappableView { [unowned self] in @@ -160,11 +162,13 @@ class ComplexLayoutViewController: ComponentViewController { } } .inset(top: 5, left: 10, bottom: 0, right: 10).visibleInset(-200).scrollView() - .onFirstReload { scrollView in - let cellFrame = scrollView.frame(id: ComplexLayoutViewController.defaultHorizontalListData[5].id)! + .showsHorizontalScrollIndicator(false) + .with(\.componentEngine.onFirstReload) { scrollView in + guard let scrollView = scrollView as? UIScrollView else { return } + let cellFrame = scrollView.componentEngine.frame(id: ComplexLayoutViewController.defaultHorizontalListData[5].id)! scrollView.scrollRectToVisible(CGRect(center: cellFrame.center, size: scrollView.bounds.size), animated: false) // scroll to item 5 as the center } - .showsHorizontalScrollIndicator(false).with(\.animator, TransformAnimator()) + .with(\.componentEngine.animator, TransformAnimator()) HStack(spacing: 10) { ViewComponent(view: resetButton).isEnabled(horizontalListData != ComplexLayoutViewController.defaultHorizontalListData).id("reset") ViewComponent(view: shuffleButton).isEnabled(!horizontalListData.isEmpty).id("shuffled") @@ -178,7 +182,7 @@ class ComplexLayoutViewController: ComponentViewController { override func viewDidLoad() { super.viewDidLoad() - componentView.animator = TransformAnimator() + componentView.componentEngine.animator = TransformAnimator() } @objc func resetHorizontalListData() { diff --git a/Examples/UIComponentExample/Examples/Complex layout/Components/IntroductionCard.swift b/Examples/UIComponentExample/Examples/Complex layout/Components/IntroductionCard.swift index f2c87954..fa82e5a4 100644 --- a/Examples/UIComponentExample/Examples/Complex layout/Components/IntroductionCard.swift +++ b/Examples/UIComponentExample/Examples/Complex layout/Components/IntroductionCard.swift @@ -44,6 +44,9 @@ struct IntroductionCard: ComponentBuilder { } .flex() } - .inset(10).defaultShadow().with(\.animator, TransformAnimator()).id("introduction") + .inset(10) + .defaultShadow() + .with(\.componentEngine.animator, TransformAnimator()) + .id("introduction") } } diff --git a/Examples/UIComponentExample/Examples/Gallery Example/GalleryViewController.swift b/Examples/UIComponentExample/Examples/Gallery Example/GalleryViewController.swift index e7733d97..80f5f4e9 100644 --- a/Examples/UIComponentExample/Examples/Gallery Example/GalleryViewController.swift +++ b/Examples/UIComponentExample/Examples/Gallery Example/GalleryViewController.swift @@ -66,7 +66,7 @@ class GalleryViewController: ComponentViewController { override func viewDidLoad() { super.viewDidLoad() - componentView.animator = TransformAnimator() + componentView.componentEngine.animator = TransformAnimator() } } diff --git a/Examples/UIComponentExample/Supporting Files/AppDelegate.swift b/Examples/UIComponentExample/Supporting Files/AppDelegate.swift index 9f0b31fa..d3878bcb 100644 --- a/Examples/UIComponentExample/Supporting Files/AppDelegate.swift +++ b/Examples/UIComponentExample/Supporting Files/AppDelegate.swift @@ -9,7 +9,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. - TappableViewConfiguration.default = TappableViewConfiguration { view, isHighlighted in + TappableViewConfig.default = TappableViewConfig { view, isHighlighted in let scale: CGFloat = isHighlighted ? 0.9 : 1.0 UIView.animate(withDuration: 0.2) { view.transform = .identity.scaledBy(x: scale, y: scale) diff --git a/Examples/UIComponentExample/Supporting Files/ComponentViewController.swift b/Examples/UIComponentExample/Supporting Files/ComponentViewController.swift index 8ef65a5e..9fd3ac5b 100644 --- a/Examples/UIComponentExample/Supporting Files/ComponentViewController.swift +++ b/Examples/UIComponentExample/Supporting Files/ComponentViewController.swift @@ -4,7 +4,7 @@ import UIComponent import UIKit class ComponentViewController: UIViewController { - let componentView = ComponentScrollView() + let componentView = UIScrollView() override func viewDidLoad() { super.viewDidLoad() @@ -20,7 +20,7 @@ class ComponentViewController: UIViewController { } func reloadComponent() { - componentView.component = component + componentView.componentEngine.component = component } var component: any Component { diff --git a/Examples/UIComponentExample/Supporting Files/Modifiers.swift b/Examples/UIComponentExample/Supporting Files/Modifiers.swift index 7af14f32..6c59321c 100644 --- a/Examples/UIComponentExample/Supporting Files/Modifiers.swift +++ b/Examples/UIComponentExample/Supporting Files/Modifiers.swift @@ -5,7 +5,7 @@ import UIKit extension Component { - func styleColor(_ tintColor: UIColor) -> UpdateComponent> { + func styleColor(_ tintColor: UIColor) -> UpdateComponent> { view() .update { $0.backgroundColor = tintColor.withAlphaComponent(0.5) @@ -16,7 +16,7 @@ extension Component { } } - func defaultShadow() -> UpdateComponent> { + func defaultShadow() -> UpdateComponent> { view() .update { $0.backgroundColor = .systemBackground diff --git a/README.md b/README.md index 496aab6b..a98b23c1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,6 @@ UIComponent is a declarative and modern framework to build user interfaces with - [Internal Architecture](https://lkzhao.com/UIComponent/documentation/uicomponent/architecture) - [Compare to SwiftUI](https://lkzhao.com/UIComponent/documentation/uicomponent/swiftuicompare) - [Full API Reference](https://lkzhao.com/UIComponent/documentation/uicomponent) -- [Version 2.0 Migration Guide](Version2MigrationGuide.md) +- [Migration Guides](https://lkzhao.com/UIComponent/documentation/uicomponent/migrationguides) > Checkout the [Examples](https://github.com/lkzhao/UIComponent/tree/master/Examples) folder for code examples. diff --git a/Sources/UIComponent/Animators/TransformAnimator.swift b/Sources/UIComponent/Animators/TransformAnimator.swift index ae59700f..7eaed200 100644 --- a/Sources/UIComponent/Animators/TransformAnimator.swift +++ b/Sources/UIComponent/Animators/TransformAnimator.swift @@ -30,8 +30,8 @@ public struct TransformAnimator: Animator { self.cascade = cascade } - public func delete(componentView: ComponentDisplayableView, view: UIView, completion: @escaping () -> Void) { - if componentView.isReloading, componentView.bounds.intersects(view.frame) { + public func delete(hostingView: UIView, view: UIView, completion: @escaping () -> Void) { + if hostingView.componentEngine.isReloading, hostingView.bounds.intersects(view.frame) { UIView.animate( withDuration: duration, delay: 0, @@ -43,7 +43,7 @@ public struct TransformAnimator: Animator { view.alpha = 0 }, completion: { _ in - if !componentView.visibleViews.contains(view) { + if !hostingView.componentEngine.visibleViews.contains(view) { view.transform = CGAffineTransform.identity view.alpha = 1 } @@ -55,11 +55,11 @@ public struct TransformAnimator: Animator { } } - public func insert(componentView: ComponentDisplayableView, view: UIView, frame: CGRect) { + public func insert(hostingView: UIView, view: UIView, frame: CGRect) { view.bounds.size = frame.size view.center = frame.center - if componentView.isReloading, componentView.hasReloaded, componentView.bounds.intersects(frame) { - let offsetTime: TimeInterval = cascade ? TimeInterval(frame.origin.distance(componentView.bounds.origin) / 3000) : 0 + if hostingView.componentEngine.isReloading, hostingView.componentEngine.hasReloaded, hostingView.bounds.intersects(frame) { + let offsetTime: TimeInterval = cascade ? TimeInterval(frame.origin.distance(hostingView.bounds.origin) / 3000) : 0 UIView.performWithoutAnimation { view.layer.transform = transform view.alpha = 0 @@ -78,7 +78,7 @@ public struct TransformAnimator: Animator { } } - public func update(componentView _: ComponentDisplayableView, view: UIView, frame: CGRect) { + public func update(hostingView _: UIView, view: UIView, frame: CGRect) { if view.center != frame.center { UIView.animate( withDuration: duration, diff --git a/Sources/UIComponent/Animators/WrapperAnimator.swift b/Sources/UIComponent/Animators/WrapperAnimator.swift index 39e99e7d..d019697b 100644 --- a/Sources/UIComponent/Animators/WrapperAnimator.swift +++ b/Sources/UIComponent/Animators/WrapperAnimator.swift @@ -11,40 +11,44 @@ public struct WrapperAnimator: Animator { /// Determines whether the `WrapperAnimator` should pass the update operation to the underlying `content` animator after executing `updateBlock`. public var passthroughUpdate: Bool = false /// A block that is executed when a new view is inserted. If `nil`, the insert operation is passed to the underlying `content` animator. - public var insertBlock: ((ComponentDisplayableView, UIView, CGRect) -> Void)? + public var insertBlock: ((UIView, UIView, CGRect) -> Void)? /// A block that is executed when a view needs to be updated. If `nil`, the update operation is passed to the underlying `content` animator. - public var updateBlock: ((ComponentDisplayableView, UIView, CGRect) -> Void)? + public var updateBlock: ((UIView, UIView, CGRect) -> Void)? /// A block that is executed when a view is deleted. If `nil`, the delete operation is passed to the underlying `content` animator. - public var deleteBlock: ((ComponentDisplayableView, UIView, @escaping () -> Void) -> Void)? + public var deleteBlock: ((UIView, UIView, @escaping () -> Void) -> Void)? - public func shift(componentView: ComponentDisplayableView, delta: CGPoint, view: UIView) { - (content ?? componentView.animator).shift(componentView: componentView, delta: delta, view: view) + public func shift(hostingView: UIView, delta: CGPoint, view: UIView) { + (content ?? hostingView.componentEngine.animator).shift( + hostingView: hostingView, + delta: delta, + view: view + ) } - public func update(componentView: ComponentDisplayableView, view: UIView, frame: CGRect) { + public func update(hostingView: UIView, view: UIView, frame: CGRect) { if let updateBlock { - updateBlock(componentView, view, frame) + updateBlock(hostingView, view, frame) if passthroughUpdate { - (content ?? componentView.animator).update(componentView: componentView, view: view, frame: frame) + (content ?? hostingView.componentEngine.animator).update(hostingView: hostingView, view: view, frame: frame) } } else { - (content ?? componentView.animator).update(componentView: componentView, view: view, frame: frame) + (content ?? hostingView.componentEngine.animator).update(hostingView: hostingView, view: view, frame: frame) } } - public func insert(componentView: ComponentDisplayableView, view: UIView, frame: CGRect) { + public func insert(hostingView: UIView, view: UIView, frame: CGRect) { if let insertBlock { - insertBlock(componentView, view, frame) + insertBlock(hostingView, view, frame) } else { - (content ?? componentView.animator).insert(componentView: componentView, view: view, frame: frame) + (content ?? hostingView.componentEngine.animator).insert(hostingView: hostingView, view: view, frame: frame) } } - public func delete(componentView: ComponentDisplayableView, view: UIView, completion: @escaping () -> Void) { + public func delete(hostingView: UIView, view: UIView, completion: @escaping () -> Void) { if let deleteBlock { - deleteBlock(componentView, view, completion) + deleteBlock(hostingView, view, completion) } else { - (content ?? componentView.animator).delete(componentView: componentView, view: view, completion: completion) + (content ?? hostingView.componentEngine.animator).delete(hostingView: hostingView, view: view, completion: completion) } } } diff --git a/Sources/UIComponent/Components/View/ComponentViewComponent.swift b/Sources/UIComponent/Components/View/ComponentViewComponent.swift deleted file mode 100644 index 5c2e52ce..00000000 --- a/Sources/UIComponent/Components/View/ComponentViewComponent.swift +++ /dev/null @@ -1,31 +0,0 @@ -// Created by Luke Zhao on 8/23/20. - -import UIKit - -/// Wraps a `component` inside a `ComponentDisplayableView`. -/// -/// This is used to power the `.view()` and `.scrollView()` modifiers. -public struct ComponentViewComponent: Component { - let component: any Component - public init(component: any Component) { - self.component = component - } - public func layout(_ constraint: Constraint) -> ComponentViewRenderNode { - let renderNode = component.layout(constraint) - return ComponentViewRenderNode(size: renderNode.size.bound(to: constraint), component: component, content: renderNode) - } -} - -/// RenderNode for the `ComponentViewComponent` -public struct ComponentViewRenderNode: RenderNode { - public let size: CGSize - public let component: any Component - public let content: any RenderNode - - public var id: String? { - content.id - } - public func updateView(_ view: View) { - view.engine.reloadWithExisting(component: component, renderNode: content) - } -} diff --git a/Sources/UIComponent/Components/View/PrimaryMenu.swift b/Sources/UIComponent/Components/View/PrimaryMenu.swift index b12caba6..27769071 100644 --- a/Sources/UIComponent/Components/View/PrimaryMenu.swift +++ b/Sources/UIComponent/Components/View/PrimaryMenu.swift @@ -51,9 +51,6 @@ public class PrimaryMenu: UIControl { set { config = newValue } } - /// The view that displays the menu's content. - let contentView = ComponentView() - /// A flag indicating whether the menu is currently being displayed. public var isShowingMenu = false @@ -81,12 +78,6 @@ public class PrimaryMenu: UIControl { set { _preferredMenuElementOrder = newValue } } - /// The component that is rendered within the `contentView`. - public var component: (any Component)? { - get { contentView.component } - set { contentView.component = newValue } - } - /// A type-erased pointer style provider. private var _pointerStyleProvider: Any? @@ -110,24 +101,14 @@ public class PrimaryMenu: UIControl { super.init(frame: frame) showsMenuAsPrimaryAction = true isContextMenuInteractionEnabled = true - addSubview(contentView) accessibilityTraits = .button - contentView.addInteraction(UIPointerInteraction(delegate: self)) + addInteraction(UIPointerInteraction(delegate: self)) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - public override func layoutSubviews() { - super.layoutSubviews() - contentView.frame = bounds - } - - public override func sizeThatFits(_ size: CGSize) -> CGSize { - contentView.sizeThatFits(size) - } - // MARK: - Touch Handling public override func touchesBegan(_ touches: Set, with event: UIEvent?) { @@ -177,7 +158,7 @@ extension PrimaryMenu: UIPointerInteractionDelegate { if let pointerStyleProvider { return pointerStyleProvider() } else { - return UIPointerStyle(effect: .automatic(UITargetedPreview(view: contentView)), shape: nil) + return UIPointerStyle(effect: .automatic(UITargetedPreview(view: self)), shape: nil) } } } diff --git a/Sources/UIComponent/Components/View/PrimaryMenuComponent.swift b/Sources/UIComponent/Components/View/PrimaryMenuComponent.swift index d1c0da24..d9047493 100644 --- a/Sources/UIComponent/Components/View/PrimaryMenuComponent.swift +++ b/Sources/UIComponent/Components/View/PrimaryMenuComponent.swift @@ -66,7 +66,7 @@ public struct PrimaryMenuRenderNode: RenderNode { public func updateView(_ view: PrimaryMenu) { view.config = config view.menuBuilder = menuBuilder - view.contentView.engine.reloadWithExisting(component: component, renderNode: content) + view.componentEngine.reloadWithExisting(component: component, renderNode: content) } } diff --git a/Sources/UIComponent/Components/View/TappableView.swift b/Sources/UIComponent/Components/View/TappableView.swift index e105276e..4568ae52 100644 --- a/Sources/UIComponent/Components/View/TappableView.swift +++ b/Sources/UIComponent/Components/View/TappableView.swift @@ -27,7 +27,7 @@ public struct TappableViewConfig { @available(*, deprecated, renamed: "TappableViewConfig") public typealias TappableViewConfiguration = TappableViewConfig -/// TappableView is a subclass of ``ComponentView`` that responds to tap and gesture events. +/// TappableView is view that respond to tap and gesture events. /// It can be configured using ``TappableViewConfig`` and supports various gestures such as tap, double tap, and long press. /// /// ### Handle Gesture @@ -68,7 +68,7 @@ public typealias TappableViewConfiguration = TappableViewConfig /// }.pointerStyleProvider { /// UIPointerStyle(...) // return the pointer style you want to be displayed /// } -open class TappableView: ComponentView { +open class TappableView: UIView { /// The configuration object for the TappableView, which defines the behavior of the view when it is tapped or highlighted. public var config: TappableViewConfig? diff --git a/Sources/UIComponent/Components/View/TappableViewComponent.swift b/Sources/UIComponent/Components/View/TappableViewComponent.swift index be00a59c..2e43b23d 100644 --- a/Sources/UIComponent/Components/View/TappableViewComponent.swift +++ b/Sources/UIComponent/Components/View/TappableViewComponent.swift @@ -64,7 +64,7 @@ public struct TappableViewRenderNode: RenderNode { public func updateView(_ view: TappableView) { view.config = config view.onTap = onTap - view.engine.reloadWithExisting(component: component, renderNode: content) + view.componentEngine.reloadWithExisting(component: component, renderNode: content) } } diff --git a/Sources/UIComponent/Components/View/ViewWrapperComponent.swift b/Sources/UIComponent/Components/View/ViewWrapperComponent.swift new file mode 100644 index 00000000..27518a9c --- /dev/null +++ b/Sources/UIComponent/Components/View/ViewWrapperComponent.swift @@ -0,0 +1,33 @@ +// Created by Luke Zhao on 8/23/20. + +import UIKit + +/// Wraps a `component` inside a `UIView`. +/// +/// This is used to power the `.view()` and `.scrollView()` modifiers. +public struct ViewWrapperComponent: Component { + let component: any Component + public init(component: any Component) { + self.component = component + } + public func layout(_ constraint: Constraint) -> ViewWrapperRenderNode { + let renderNode = component.layout(constraint) + return ViewWrapperRenderNode(size: renderNode.size.bound(to: constraint), + component: component, + content: renderNode) + } +} + +/// RenderNode for the `ViewWrapperComponent` +public struct ViewWrapperRenderNode: RenderNode { + public let size: CGSize + public let component: any Component + public let content: any RenderNode + + public var id: String? { + content.id + } + public func updateView(_ view: View) { + view.componentEngine.reloadWithExisting(component: component, renderNode: content) + } +} diff --git a/Sources/UIComponent/Core/ComponentView/ComponentDisplayableView.swift b/Sources/UIComponent/Core/ComponentView/ComponentDisplayableView.swift index d7bbc4cf..71d53ce2 100644 --- a/Sources/UIComponent/Core/ComponentView/ComponentDisplayableView.swift +++ b/Sources/UIComponent/Core/ComponentView/ComponentDisplayableView.swift @@ -2,60 +2,64 @@ import UIKit -/// A protocol that defines a view capable of displaying components. -public protocol ComponentDisplayableView: UIView { - var engine: ComponentEngine { get } -} +/// A helper protocol that provides easier access to the underlying component engine's methods +public protocol ComponentDisplayableView: UIView {} -/// Extension to provide default implementations and additional functionalities to `ComponentDisplayableView`. +/// Extension to provide easier access to the underlying component engine's methods extension ComponentDisplayableView { /// The component to be rendered by this component displayable view. public var component: (any Component)? { - get { engine.component } - set { engine.component = newValue } + get { componentEngine.component } + set { componentEngine.component = newValue } } /// The default animator for the component being rendered by this view. public var animator: Animator { - get { engine.animator } - set { engine.animator = newValue } + get { componentEngine.animator } + set { componentEngine.animator = newValue } + } + + /// A closure that is called after the first reload. + public var onFirstReload: ((UIView) -> Void)? { + get { componentEngine.onFirstReload } + set { componentEngine.onFirstReload = newValue } } - + /// The render node associated with the current component. public var renderNode: (any RenderNode)? { - engine.renderNode + componentEngine.renderNode } /// The visible frame insets that are applied to the viewport before fetching the views from the renderNode. public var visibleFrameInsets: UIEdgeInsets { - get { engine.visibleFrameInsets } - set { engine.visibleFrameInsets = newValue } + get { componentEngine.visibleFrameInsets } + set { componentEngine.visibleFrameInsets = newValue } } /// The number of times this view has reloaded. public var reloadCount: Int { - engine.reloadCount + componentEngine.reloadCount } /// A Boolean value indicating whether this view is scheduled to reload during the next layout cycle. public var needsReload: Bool { - engine.needsReload + componentEngine.needsReload } /// A Boolean value indicating whether this view is scheduled to render during the next layout cycle. public var needsRender: Bool { - engine.needsRender + componentEngine.needsRender } /// A Boolean value indicating whether this view is currently reloading. public var isReloading: Bool { - engine.isReloading + componentEngine.isReloading } /// A Boolean value indicating whether this view is currently rendering. public var isRendering: Bool { - engine.isRendering + componentEngine.isRendering } /// A Boolean value indicating whether this view has reloaded at least once. @@ -63,66 +67,67 @@ extension ComponentDisplayableView { /// The views that are currently visible and being rendered by this view. public var visibleViews: [UIView] { - engine.visibleViews + componentEngine.visibleViews } /// The renderables that are currently visible and being rendered by this view. public var visibleRenderable: [Renderable] { - engine.visibleRenderable + componentEngine.visibleRenderables } /// The bounds of this view when the last render occurred. public var lastRenderBounds: CGRect { - engine.lastRenderBounds + componentEngine.lastRenderBounds } /// The content offset changes since the last reload. public var contentOffsetDelta: CGPoint { - engine.contentOffsetDelta + componentEngine.contentOffsetDelta } /// Marks this view as needing a reload. public func setNeedsReload() { - engine.setNeedsReload() + componentEngine.setNeedsReload() } /// Marks this view as needing a render. public func setNeedsRender() { - engine.setNeedsRender() + componentEngine.setNeedsRender() } /// Ensures that the zoom view is centered. public func ensureZoomViewIsCentered() { - engine.ensureZoomViewIsCentered() + componentEngine.ensureZoomViewIsCentered() } /// Reloads the data and optionally adjusts the content offset. public func reloadData(contentOffsetAdjustFn: (() -> CGPoint)? = nil) { - engine.reloadData(contentOffsetAdjustFn: contentOffsetAdjustFn) + componentEngine.reloadData(contentOffsetAdjustFn: contentOffsetAdjustFn) } -} -/// Extension to provide additional functionalities to `ComponentDisplayableView` related to view lookup and frame calculation. -extension ComponentDisplayableView { /// Returns the view at a given point if it exists within the visible views. public func view(at point: CGPoint) -> UIView? { - visibleViews.first { - $0.point(inside: $0.convert(point, from: self), with: nil) - } + componentEngine.view(at: point) } /// Returns the frame associated with a given identifier if it exists within the render node. public func frame(id: String) -> CGRect? { - engine.renderNode?.frame(id: id) + componentEngine.frame(id: id) } /// Returns the visible view associated with a given identifier if it exists within the visible renderables. public func visibleView(id: String) -> UIView? { - for (view, renderable) in zip(visibleViews, visibleRenderable) { - if renderable.id == id { - return view - } - } - return nil + componentEngine.visibleView(id: id) + } +} + +extension ComponentDisplayableView where Self: UIScrollView { + public var contentView: UIView? { + get { componentEngine.contentView } + set { componentEngine.contentView = newValue } + } + + @discardableResult public func scrollTo(id: String, animated: Bool) -> Bool { + componentEngine.scrollTo(id: id, animated: animated) } } diff --git a/Sources/UIComponent/Core/ComponentView/ComponentEngine.swift b/Sources/UIComponent/Core/ComponentView/ComponentEngine.swift index 4a04a9a1..41e31522 100644 --- a/Sources/UIComponent/Core/ComponentView/ComponentEngine.swift +++ b/Sources/UIComponent/Core/ComponentView/ComponentEngine.swift @@ -3,23 +3,22 @@ import UIKit -/// Protocol defining a delegate responsible for determining if a component view should be reloaded. -public protocol ComponentReloadDelegate: AnyObject { - /// Asks the delegate if the component view should be reloaded. - /// - Parameter view: The `ComponentDisplayableView` that is asking for permission to reload. +/// Protocol defining a delegate responsible for determining if a component engine should be reloaded. +public protocol ComponentEngineReloadDelegate: AnyObject { + /// Asks the delegate if the component engine should be reloaded. + /// - Parameter view: The `UIView` that is asking for permission to reload. /// - Returns: A Boolean value indicating whether the view should be reloaded. - func componentViewShouldReload(_ view: ComponentDisplayableView) -> Bool + func componentEngineShouldReload(_ view: UIView) -> Bool } /// `ComponentEngine` is the main class that powers the rendering of components. -/// It manages a `ComponentDisplayableView` and handles rendering the component to the view. -/// See `ComponentView` for a sample implementation. -public class ComponentEngine { +/// It manages a `UIView` and handles rendering the component to the view. +public final class ComponentEngine { /// A static property to disable animations during view updates. public static var disableUpdateAnimation: Bool = false - /// A static weak reference to a delegate that decides if a component view should reload. - public static weak var reloadDelegate: ComponentReloadDelegate? + /// A static weak reference to a delegate that decides if a component engine should reload. + public static weak var reloadDelegate: ComponentEngineReloadDelegate? private static let asyncLayoutQueue = DispatchQueue(label: "com.component.layout", qos: .userInteractive) @@ -27,68 +26,68 @@ public class ComponentEngine { public var asyncLayout = false /// The view that is managed by this engine. - weak var view: ComponentDisplayableView? + weak var view: UIView? /// The component that will be rendered. - var component: (any Component)? { + public var component: (any Component)? { didSet { setNeedsReload() } } /// The default animator for the components rendered by this engine. - var animator: Animator = BaseAnimator() { + public var animator: Animator = BaseAnimator() { didSet { setNeedsRender() } } /// The current `RenderNode`. This is `nil` before the layout is done. - var renderNode: (any RenderNode)? + public private(set) var renderNode: (any RenderNode)? /// Only render the renderNode, skipping layout. - var renderOnly: Bool = false + public private(set) var renderOnly: Bool = false /// Internal state to track if a reload is needed. - var needsReload = true - + public private(set) var needsReload = true + /// Internal state to track if a render is needed. - var needsRender = false + public private(set) var needsRender = false /// The number of times the view has been reloaded. - var reloadCount = 0 - + public private(set) var reloadCount = 0 + /// Internal state to track if the engine is currently rendering. - var isRendering = false - + public private(set) var isRendering = false + /// Internal state to track if the engine is currently reloading. - var isReloading = false - + public private(set) var isReloading = false + /// A computed property to determine if reloading is allowed by consulting the `reloadDelegate`. var allowReload: Bool { guard let view, let reloadDelegate = Self.reloadDelegate else { return true } - return reloadDelegate.componentViewShouldReload(view) + return reloadDelegate.componentEngineShouldReload(view) } /// Insets for the visible frame. This will be applied to the `visibleFrame` used to retrieve views for the viewport. - var visibleFrameInsets: UIEdgeInsets = .zero + public var visibleFrameInsets: UIEdgeInsets = .zero /// A flag indicating whether this engine has rendered at least once. - var hasReloaded: Bool { reloadCount > 0 } + public var hasReloaded: Bool { reloadCount > 0 } /// An array of visible views on the screen. - var visibleViews: [UIView] = [] - + public private(set) var visibleViews: [UIView] = [] + /// An array of `Renderable` objects corresponding to the visible views. - var visibleRenderable: [Renderable] = [] + public private(set) var visibleRenderables: [Renderable] = [] /// The bounds of the view during the last reload. - var lastRenderBounds: CGRect = .zero + public private(set) var lastRenderBounds: CGRect = .zero /// The change in content offset since the last reload. - var contentOffsetDelta: CGPoint = .zero + public private(set) var contentOffsetDelta: CGPoint = .zero /// A closure that is called after the first reload. - var onFirstReload: (() -> Void)? + public var onFirstReload: ((UIView) -> Void)? /// A view used to support zooming. Setting a `contentView` will render all views inside the content view. - var contentView: UIView? { + public var contentView: UIView? { didSet { oldValue?.removeFromSuperview() if let contentView { @@ -104,7 +103,7 @@ public class ComponentEngine { public var centerContentViewHorizontally = true /// The size of the content within the view. - var contentSize: CGSize = .zero { + public private(set) var contentSize: CGSize = .zero { didSet { (view as? UIScrollView)?.contentSize = contentSize } @@ -137,8 +136,8 @@ public class ComponentEngine { } /// Initializes a new `ComponentEngine` with the given view. - /// - Parameter view: The `ComponentDisplayableView` to be managed by the engine. - init(view: ComponentDisplayableView) { + /// - Parameter view: The `UIView` to be managed by the engine. + init(view: UIView) { self.view = view } @@ -154,29 +153,29 @@ public class ComponentEngine { } /// Marks the view as needing a reload (layout + render) and schedules an update. - func setNeedsReload() { + public func setNeedsReload() { needsReload = true view?.setNeedsLayout() } /// Marks the view as needing a render (no layout) and schedules an update. /// A renderNode must be present - func setNeedsRender() { + public func setNeedsRender() { needsRender = true view?.setNeedsLayout() } /// Reloads the view, rendering the component. /// - Parameter contentOffsetAdjustFn: An optional closure that adjusts the content offset after the layout is finished, but berfore any view is rendered. - func reloadData(contentOffsetAdjustFn: (() -> CGPoint)? = nil) { + public func reloadData(contentOffsetAdjustFn: (() -> CGPoint)? = nil) { guard !isReloading, allowReload else { return } isReloading = true defer { reloadCount += 1 needsReload = false isReloading = false - if let onFirstReload, reloadCount == 1 { - onFirstReload() + if let onFirstReload, let view, reloadCount == 1 { + onFirstReload(view) } } @@ -192,13 +191,13 @@ public class ComponentEngine { private var asyncLayoutID: UUID? private func layoutComponentAsync(contentOffsetAdjustFn: (() -> CGPoint)?) { - guard let componentView = view, let component else { return } + guard let view, let component else { return } let adjustedSize = adjustedSize let asyncLayoutID = UUID() self.asyncLayoutID = asyncLayoutID Self.asyncLayoutQueue.async { [weak self] in - let renderNode = EnvironmentValues.with(values: .init(\.currentComponentView, value: componentView)) { + let renderNode = EnvironmentValues.with(values: .init(\.hostingView, value: view)) { component.layout(Constraint(maxSize: adjustedSize)) } DispatchQueue.main.async { @@ -209,9 +208,9 @@ public class ComponentEngine { } private func layoutComponent(contentOffsetAdjustFn: (() -> CGPoint)?) { - guard let componentView = view, let component else { return } + guard let view, let component else { return } - let renderNode = EnvironmentValues.with(values: .init(\.currentComponentView, value: componentView)) { + let renderNode = EnvironmentValues.with(values: .init(\.hostingView, value: view)) { component.layout(Constraint(maxSize: adjustedSize)) } @@ -236,14 +235,14 @@ public class ComponentEngine { /// Renders the render node based on the visibleFrame, optionally updating views. /// - Parameters: /// - updateViews: A Boolean value that determines if the views should be updated. - func render(updateViews: Bool) { - guard let componentView = view, allowReload, !isRendering, let renderNode else { return } + private func render(updateViews: Bool) { + guard let view, allowReload, !isRendering, let renderNode else { return } isRendering = true - animator.willUpdate(componentView: componentView) + animator.willUpdate(hostingView: view) let visibleFrame = (contentView?.convert(bounds, from: view) ?? bounds).inset(by: visibleFrameInsets) - var newVisibleRenderable = renderNode._visibleRenderables(in: visibleFrame) + var newVisibleRenderables = renderNode._visibleRenderables(in: visibleFrame) if contentSize != renderNode.size * zoomScale { // update contentSize if it is changed. Some renderNodes update @@ -253,70 +252,70 @@ public class ComponentEngine { // construct private identifiers var newIdentifierSet = [String: Int]() - for (index, renderable) in newVisibleRenderable.enumerated() { + for (index, renderable) in newVisibleRenderables.enumerated() { var count = 1 let initialId = renderable.id var finalId = initialId while newIdentifierSet[finalId] != nil { finalId = initialId + String(count) - newVisibleRenderable[index].id = finalId + newVisibleRenderables[index].id = finalId count += 1 } newIdentifierSet[finalId] = index } - var newViews = [UIView?](repeating: nil, count: newVisibleRenderable.count) + var newViews = [UIView?](repeating: nil, count: newVisibleRenderables.count) // 1st pass, delete all removed cells and move existing cells for index in 0.. CGSize { + public func sizeThatFits(_ size: CGSize) -> CGSize { component?.layout(Constraint(maxSize: size)).size ?? .zero } @@ -361,8 +360,8 @@ public class ComponentEngine { /// - identifier: The current identifier of the cell. /// - newIdentifier: The new identifier to replace the current identifier. public func replace(identifier: String, with newIdentifier: String) { - for (i, renderable) in visibleRenderable.enumerated() where renderable.id == identifier { - visibleRenderable[i].id = newIdentifier + for (i, renderable) in visibleRenderables.enumerated() where renderable.id == identifier { + visibleRenderables[i].id = newIdentifier break } } @@ -378,3 +377,39 @@ public class ComponentEngine { self.renderOnly = true } } + + +/// Extension to provide additional functionalities to view lookup and frame calculation. +extension ComponentEngine { + /// Returns the view at a given point if it exists within the visible views. + public func view(at point: CGPoint) -> UIView? { + guard let view else { return nil } + return visibleViews.first { + $0.point(inside: $0.convert(point, from: view), with: nil) + } + } + + /// Returns the frame associated with a given identifier if it exists within the render node. + public func frame(id: String) -> CGRect? { + renderNode?.frame(id: id) + } + + /// Returns the visible view associated with a given identifier if it exists within the visible renderables. + public func visibleView(id: String) -> UIView? { + for (view, renderable) in zip(visibleViews, visibleRenderables) { + if renderable.id == id { + return view + } + } + return nil + } + + @discardableResult public func scrollTo(id: String, animated: Bool) -> Bool { + if let frame = renderNode?.frame(id: id), let view = view as? UIScrollView { + view.scrollRectToVisible(frame, animated: animated) + return true + } else { + return false + } + } +} diff --git a/Sources/UIComponent/Core/ComponentView/ComponentScrollView.swift b/Sources/UIComponent/Core/ComponentView/ComponentScrollView.swift deleted file mode 100644 index d6932d5b..00000000 --- a/Sources/UIComponent/Core/ComponentView/ComponentScrollView.swift +++ /dev/null @@ -1,65 +0,0 @@ -// Created by Luke Zhao on 8/27/20. - -import UIKit - -/// A UIScrollView that can render components -/// -/// You can set the ``component`` property with your component tree for it to render -/// The render happens on the next layout cycle. But you can call ``reloadData`` to force it to render. -/// -/// Most of the code is written in ``ComponentDisplayableView``, since both ``ComponentView`` -/// and ``ComponentScrollView`` supports rendering components. -/// -/// See ``ComponentDisplayableView`` for usage details. -open class ComponentScrollView: UIScrollView, ComponentDisplayableView { - lazy public var engine: ComponentEngine = ComponentEngine(view: self) - - public var onFirstReload: ((ComponentScrollView) -> Void)? { - didSet { - if let onFirstReload { - engine.onFirstReload = { [weak self] in - guard let self = self else { return } - onFirstReload(self) - } - } else { - engine.onFirstReload = nil - } - } - } - - open override var contentOffset: CGPoint { - didSet { - setNeedsLayout() - } - } - - public var contentView: UIView? { - get { return engine.contentView } - set { engine.contentView = newValue } - } - - open override func safeAreaInsetsDidChange() { - super.safeAreaInsetsDidChange() - if contentInsetAdjustmentBehavior != .never { - setNeedsReload() - } - } - - open override func layoutSubviews() { - super.layoutSubviews() - engine.layoutSubview() - } - - open override func sizeThatFits(_ size: CGSize) -> CGSize { - engine.sizeThatFits(size) - } - - @discardableResult open func scrollTo(id: String, animated: Bool) -> Bool { - if let frame = engine.renderNode?.frame(id: id) { - scrollRectToVisible(frame, animated: animated) - return true - } else { - return false - } - } -} diff --git a/Sources/UIComponent/Core/ComponentView/ComponentView.swift b/Sources/UIComponent/Core/ComponentView/ComponentView.swift index 5fe33b38..6f5ccc7a 100644 --- a/Sources/UIComponent/Core/ComponentView/ComponentView.swift +++ b/Sources/UIComponent/Core/ComponentView/ComponentView.swift @@ -2,7 +2,8 @@ import UIKit -/// A UIView that can render components +/// A `UIView` that can render components. +/// It provides simple access to the properties and method of the underlying ``ComponentEngine`` /// /// You can set the ``component`` property with your component tree for it to render /// The render happens on the next layout cycle. But you can call ``reloadData`` to force it to render. @@ -12,19 +13,17 @@ import UIKit /// /// See ``ComponentDisplayableView`` for usage details. open class ComponentView: UIView, ComponentDisplayableView { - /// The `ComponentEngine` that handles layout logic for the `ComponentView`. - lazy public var engine: ComponentEngine = ComponentEngine(view: self) - - /// Overrides the `layoutSubviews` method to integrate the component engine's layout logic. - open override func layoutSubviews() { - super.layoutSubviews() - engine.layoutSubview() - } +} - /// Overrides the `sizeThatFits` method to calculate the size of the view based on the component engine. - /// - Parameter size: The size constraint to consider for the view. - /// - Returns: The preferred size of the view within the given constraints. - open override func sizeThatFits(_ size: CGSize) -> CGSize { - engine.sizeThatFits(size) - } +/// A `UIScrollView` that can render components +/// It provides simple access to the properties and method of the underlying ``ComponentEngine`` +/// +/// You can set the ``component`` property with your component tree for it to render +/// The render happens on the next layout cycle. But you can call ``reloadData`` to force it to render. +/// +/// Most of the code is written in ``ComponentDisplayableView``, since both ``ComponentView`` +/// and ``ComponentScrollView`` supports rendering components. +/// +/// See ``ComponentDisplayableView`` for usage details. +open class ComponentScrollView: UIScrollView, ComponentDisplayableView { } diff --git a/Sources/UIComponent/Core/ComponentView/UIView+ComponentEngine.swift b/Sources/UIComponent/Core/ComponentView/UIView+ComponentEngine.swift new file mode 100644 index 00000000..36ddd579 --- /dev/null +++ b/Sources/UIComponent/Core/ComponentView/UIView+ComponentEngine.swift @@ -0,0 +1,100 @@ +// +// UIView+ComponentEngine.swift +// +// +// Created by Luke Zhao on 7/17/24. +// + +import UIKit + +extension UIView { + // Access to the underlying Component Engine + public var componentEngine: ComponentEngine { + get { + if let componentEngine = _componentEngine { + return componentEngine + } + let componentEngine = ComponentEngine(view: self) + _ = UIView.swizzle_setBounds + _ = UIView.swizzle_layoutSubviews + _ = UIView.swizzle_sizeThatFits + _ = UIScrollView.swizzle_safeAreaInsetsDidChange + _componentEngine = componentEngine + return componentEngine + } + } +} + +private struct AssociatedKeys { + static var componentEngine: Void? + static var onFirstReload: Void? +} + +extension UIView { + fileprivate var _componentEngine: ComponentEngine? { + get { + objc_getAssociatedObject(self, &AssociatedKeys.componentEngine) as? ComponentEngine + } + set { + objc_setAssociatedObject( + self, + &AssociatedKeys.componentEngine, + newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + static let swizzle_sizeThatFits: Void = { + guard let originalMethod = class_getInstanceMethod(UIView.self, #selector(sizeThatFits(_:))), + let swizzledMethod = class_getInstanceMethod(UIView.self, #selector(swizzled_sizeThatFits(_:))) + else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + + @objc func swizzled_sizeThatFits(_ size: CGSize) -> CGSize { + _componentEngine?.sizeThatFits(size) ?? swizzled_sizeThatFits(size) + } + + static let swizzle_layoutSubviews: Void = { + guard let originalMethod = class_getInstanceMethod(UIView.self, #selector(layoutSubviews)), + let swizzledMethod = class_getInstanceMethod(UIView.self, #selector(swizzled_layoutSubviews)) + else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + + @objc func swizzled_layoutSubviews() { + swizzled_layoutSubviews() + _componentEngine?.layoutSubview() + } + + static let swizzle_setBounds: Void = { + guard let originalMethod = class_getInstanceMethod(UIView.self, NSSelectorFromString("setBounds:")), + let swizzledMethod = class_getInstanceMethod(UIView.self, #selector(swizzled_setBounds(_:))) + else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + + @objc func swizzled_setBounds(_ bounds: CGRect) { + swizzled_setBounds(bounds) + _componentEngine?.setNeedsRender() + } +} + +extension UIScrollView { + static let swizzle_safeAreaInsetsDidChange: Void = { + guard let originalMethod = class_getInstanceMethod(UIScrollView.self, #selector(safeAreaInsetsDidChange)), + let swizzledMethod = class_getInstanceMethod(UIScrollView.self, #selector(swizzled_safeAreaInsetsDidChange)) + else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + + @objc func swizzled_safeAreaInsetsDidChange() { + guard responds(to: #selector(swizzled_safeAreaInsetsDidChange)) else { return } + swizzled_safeAreaInsetsDidChange() + if let componentEngine = _componentEngine, contentInsetAdjustmentBehavior != .never { + componentEngine.setNeedsReload() + } + } +} + diff --git a/Sources/UIComponent/Core/Model/Animator.swift b/Sources/UIComponent/Core/Model/Animator.swift index ef8f52d7..59516ecb 100644 --- a/Sources/UIComponent/Core/Model/Animator.swift +++ b/Sources/UIComponent/Core/Model/Animator.swift @@ -3,42 +3,42 @@ import UIKit -/// Animator is a base class that provides default implementations for animations -/// related to the insertion, deletion, and updating of views within a `ComponentDisplayableView`. +/// `Animator` is a base class that provides default implementations for animations +/// related to the insertion, deletion, and updating of views. /// Subclasses can override these methods to provide custom animation behavior. public protocol Animator { - /// Called before ComponentView perform any update to the cells. - /// This method is only called when your animator is the componentView's root animator (i.e. componentView.animator) + /// Called before the component engine perform any update to the cells. + /// This method is only called when your animator is the `ComponentEngine`'s root animator (i.e. `componentEngine.animator`) /// /// - Parameters: - /// - componentView: the ComponentView performing the update - func willUpdate(componentView: ComponentDisplayableView) + /// - hostingView: source view that is performing the update + func willUpdate(hostingView: UIView) - /// Called when ComponentView inserts a view into its subviews. + /// Called when the component engine inserts a view into its subviews. /// /// Perform any insertion animation when needed /// /// - Parameters: - /// - componentView: source ComponentView + /// - hostingView: source view that host the component /// - view: the view being inserted /// - frame: frame provided by the layout func insert( - componentView: ComponentDisplayableView, + hostingView: UIView, view: UIView, frame: CGRect ) - /// Called when ComponentView deletes a view from its subviews. + /// Called when the component engine deletes a view from its subviews. /// /// Perform any deletion animation, then call the `completion` block when finished. /// /// - Parameters: - /// - componentView: source ComponentView + /// - hostingView: source view that host the component /// - view: the view being deleted /// - completion: call this block when finished func delete( - componentView: ComponentDisplayableView, + hostingView: UIView, view: UIView, completion: @escaping () -> Void ) @@ -49,11 +49,11 @@ public protocol Animator { /// * the view's screen position changed when user scrolls /// /// - Parameters: - /// - componentView: source ComponentView + /// - hostingView: source view that host the component /// - view: the view being updated /// - frame: frame provided by the layout func update( - componentView: ComponentDisplayableView, + hostingView: UIView, view: UIView, frame: CGRect ) @@ -61,30 +61,30 @@ public protocol Animator { /// Called when contentOffset changes during reloadData /// /// - Parameters: - /// - componentView: source ComponentView + /// - hostingView: source view that host the component /// - delta: changes in contentOffset /// - view: the view being updated - func shift(componentView: ComponentDisplayableView, delta: CGPoint, view: UIView) + func shift(hostingView: UIView, delta: CGPoint, view: UIView) } // MARK: - Default implementation public extension Animator { - func willUpdate(componentView: ComponentDisplayableView) {} + func willUpdate(hostingView: UIView) {} func insert( - componentView: ComponentDisplayableView, + hostingView: UIView, view: UIView, frame: CGRect ) {} func delete( - componentView: ComponentDisplayableView, + hostingView: UIView, view: UIView, completion: @escaping () -> Void ) { completion() } func update( - componentView: ComponentDisplayableView, + hostingView: UIView, view: UIView, frame: CGRect ) { @@ -95,7 +95,7 @@ public extension Animator { view.center = frame.center } } - func shift(componentView: ComponentDisplayableView, delta: CGPoint, view: UIView) { + func shift(hostingView: UIView, delta: CGPoint, view: UIView) { view.center += delta } } diff --git a/Sources/UIComponent/Core/Model/Component/Component+Modifier.swift b/Sources/UIComponent/Core/Model/Component/Component+Modifier.swift index d90dd5cc..705d52d4 100644 --- a/Sources/UIComponent/Core/Model/Component/Component+Modifier.swift +++ b/Sources/UIComponent/Core/Model/Component/Component+Modifier.swift @@ -217,16 +217,16 @@ extension Component { // MARK: - View wrapper modifiers - /// Wraps the component in a `ComponentViewComponent` with a generic `ComponentView`. - /// - Returns: A `ComponentViewComponent` that renders the component within a `ComponentView`. - public func view() -> ComponentViewComponent { - ComponentViewComponent(component: self) + /// Wraps the component in a `UIView`. + /// - Returns: A `ViewWrapperComponent` that renders the component within a UIView. + public func view() -> ViewWrapperComponent { + ViewWrapperComponent(component: self) } - /// Wraps the component in a `ComponentViewComponent` with a `ComponentScrollView`. - /// - Returns: A `ComponentViewComponent` that renders the component within a `ComponentScrollView`. - public func scrollView() -> ComponentViewComponent { - ComponentViewComponent(component: self) + /// Wraps the component in a `UIScrollView`. + /// - Returns: A `ViewWrapperComponent` that renders the component within a `UIScrollView`. + public func scrollView() -> ViewWrapperComponent { + ViewWrapperComponent(component: self) } // MARK: - Background modifiers @@ -675,7 +675,7 @@ extension Component { /// - passthrough: A Boolean value that determines whether the animator update method will be called for the content component. /// - updateBlock: A closure that is called to perform the layout update animation. /// - Returns: An `AnimatorWrapperComponent` containing the modified component. - public func animateUpdate(passthrough: Bool = false, _ updateBlock: @escaping ((ComponentDisplayableView, UIView, CGRect) -> Void)) -> AnimatorWrapperComponent { + public func animateUpdate(passthrough: Bool = false, _ updateBlock: @escaping ((UIView, UIView, CGRect) -> Void)) -> AnimatorWrapperComponent { ModifierComponent(content: self) { $0.animateUpdate(passthrough: passthrough, updateBlock) } @@ -684,7 +684,7 @@ extension Component { /// Animates the insertion of the component. /// - Parameter insertBlock: A closure that is called to perform the insertion animation. /// - Returns: An `AnimatorWrapperComponent` containing the modified component. - public func animateInsert(_ insertBlock: @escaping ((ComponentDisplayableView, UIView, CGRect) -> Void)) -> AnimatorWrapperComponent { + public func animateInsert(_ insertBlock: @escaping ((UIView, UIView, CGRect) -> Void)) -> AnimatorWrapperComponent { ModifierComponent(content: self) { $0.animateInsert(insertBlock) } @@ -693,7 +693,7 @@ extension Component { /// Animates the deletion of the component. /// - Parameter deleteBlock: A closure that is called to perform the deletion animation. /// - Returns: An `AnimatorWrapperComponent` containing the modified component. - public func animateDelete(_ deleteBlock: @escaping (ComponentDisplayableView, UIView, @escaping () -> Void) -> Void) -> AnimatorWrapperComponent { + public func animateDelete(_ deleteBlock: @escaping (UIView, UIView, @escaping () -> Void) -> Void) -> AnimatorWrapperComponent { ModifierComponent(content: self) { $0.animateDelete(deleteBlock) } diff --git a/Sources/UIComponent/Core/Model/Environment/Environment.swift b/Sources/UIComponent/Core/Model/Environment/Environment.swift index a4772faa..fdc866e1 100644 --- a/Sources/UIComponent/Core/Model/Environment/Environment.swift +++ b/Sources/UIComponent/Core/Model/Environment/Environment.swift @@ -14,15 +14,15 @@ import Foundation /// /// In your ``Component`` or ``ComponentBuilder``, use the ``Environment`` property wrapper to create a property that can access the environment value. /// ```swift -/// @Environment(\.currentComponentView) var currentComponentView +/// @Environment(\.hostingView) var hostingView /// ``` /// /// Inside ``Component/layout(_:)`` or ``ComponentBuilder/build()`` you can access the environment value directly. /// ```swift /// VStack { /// // ... -/// }.inset { [weak currentComponentView] _ in -/// currentComponentView?.safeAreaInsets ?? .zero +/// }.inset { [weak hostingView] _ in +/// hostingView?.safeAreaInsets ?? .zero /// } /// ``` /// diff --git a/Sources/UIComponent/Core/Model/Environment/EnvironmentKey/CurrentComponentViewEnvironmentKey.swift b/Sources/UIComponent/Core/Model/Environment/EnvironmentKey/CurrentComponentViewEnvironmentKey.swift deleted file mode 100644 index c89b5625..00000000 --- a/Sources/UIComponent/Core/Model/Environment/EnvironmentKey/CurrentComponentViewEnvironmentKey.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// File.swift -// -// -// Created by Luke Zhao on 1/20/24. -// - -import Foundation - -/// A environment key that holds a reference to the current `ComponentDisplayableView` displaying the component. -public struct CurrentComponentViewEnvironmentKey: EnvironmentKey { - public static var defaultValue: ComponentDisplayableView? { - nil - } - public static var isWeak: Bool { - true - } -} - -public extension EnvironmentValues { - /// The current ``ComponentView`` displaying the component, if one exists. - /// This is a built-in environment value that is automatically populated if the Component is layout during a ComponentView reload. - /// - /// You can access the current ``ComponentView`` via the ``Environment`` property wrapper inside the ``Component/layout(_:)`` method: - /// ```swift - /// @Environment(\.currentComponentView) var currentComponentView - /// ``` - var currentComponentView: ComponentDisplayableView? { - get { self[CurrentComponentViewEnvironmentKey.self] } - set { self[CurrentComponentViewEnvironmentKey.self] = newValue } - } -} diff --git a/Sources/UIComponent/Core/Model/Environment/EnvironmentKey/HostingViewEnvironmentKey.swift b/Sources/UIComponent/Core/Model/Environment/EnvironmentKey/HostingViewEnvironmentKey.swift new file mode 100644 index 00000000..839230c2 --- /dev/null +++ b/Sources/UIComponent/Core/Model/Environment/EnvironmentKey/HostingViewEnvironmentKey.swift @@ -0,0 +1,32 @@ +// +// HostingViewEnvironmentKey.swift +// +// +// Created by Luke Zhao on 1/20/24. +// + +import UIKit + +/// A environment key that holds a reference to the current `UIView` displaying the component. +public struct HostingViewEnvironmentKey: EnvironmentKey { + public static var defaultValue: UIView? { + nil + } + public static var isWeak: Bool { + true + } +} + +public extension EnvironmentValues { + /// The current UIView displaying the component, if one exists. + /// This is a built-in environment value that is automatically populated during a reload. + /// + /// You can access the current hosting view via the ``Environment`` property wrapper inside the ``Component/layout(_:)`` method: + /// ```swift + /// @Environment(\.hostingView) var hostingView + /// ``` + var hostingView: UIView? { + get { self[HostingViewEnvironmentKey.self] } + set { self[HostingViewEnvironmentKey.self] = newValue } + } +} diff --git a/Sources/UIComponent/Core/Model/RenderNode/RenderNode+Modifiers.swift b/Sources/UIComponent/Core/Model/RenderNode/RenderNode+Modifiers.swift index 04b6c18b..ba4fe86d 100644 --- a/Sources/UIComponent/Core/Model/RenderNode/RenderNode+Modifiers.swift +++ b/Sources/UIComponent/Core/Model/RenderNode/RenderNode+Modifiers.swift @@ -2,7 +2,7 @@ import UIKit -/// A render node that store an update block to be applied to the view when the ComponentView reloads. +/// A render node that store an update block to be applied to the view during reloads. public struct UpdateRenderNode: RenderNodeWrapper { public let content: Content public let update: (Content.View) -> Void @@ -107,9 +107,9 @@ extension RenderNode { public struct AnimatorWrapperRenderNode: RenderNodeWrapper { public let content: Content var passthroughUpdate: Bool - var insertBlock: ((ComponentDisplayableView, UIView, CGRect) -> Void)? - var updateBlock: ((ComponentDisplayableView, UIView, CGRect) -> Void)? - var deleteBlock: ((ComponentDisplayableView, UIView, @escaping () -> Void) -> Void)? + var insertBlock: ((UIView, UIView, CGRect) -> Void)? + var updateBlock: ((UIView, UIView, CGRect) -> Void)? + var deleteBlock: ((UIView, UIView, @escaping () -> Void) -> Void)? public var animator: Animator? { var wrapper = WrapperAnimator() wrapper.content = content.animator @@ -127,21 +127,21 @@ extension RenderNode { /// - passthrough: A Boolean value that determines whether the update should be passed through to the next animator. /// - updateBlock: A closure that defines the animation for updating the view. /// - Returns: An `AnimatorWrapperRenderNode` configured with the update animation. - func animateUpdate(passthrough: Bool = false, _ updateBlock: @escaping ((ComponentDisplayableView, UIView, CGRect) -> Void)) -> AnimatorWrapperRenderNode { + func animateUpdate(passthrough: Bool = false, _ updateBlock: @escaping ((UIView, UIView, CGRect) -> Void)) -> AnimatorWrapperRenderNode { AnimatorWrapperRenderNode(content: self, passthroughUpdate: passthrough, updateBlock: updateBlock) } /// Applies an insert animation to the render node. /// - Parameter insertBlock: A closure that defines the animation for inserting the view. /// - Returns: An `AnimatorWrapperRenderNode` configured with the insert animation. - func animateInsert(_ insertBlock: @escaping (ComponentDisplayableView, UIView, CGRect) -> Void) -> AnimatorWrapperRenderNode { + func animateInsert(_ insertBlock: @escaping (UIView, UIView, CGRect) -> Void) -> AnimatorWrapperRenderNode { AnimatorWrapperRenderNode(content: self, passthroughUpdate: false, insertBlock: insertBlock) } /// Applies a delete animation to the render node. /// - Parameter deleteBlock: A closure that defines the animation for deleting the view. /// - Returns: An `AnimatorWrapperRenderNode` configured with the delete animation. - func animateDelete(_ deleteBlock: @escaping (ComponentDisplayableView, UIView, @escaping () -> Void) -> Void) -> AnimatorWrapperRenderNode { + func animateDelete(_ deleteBlock: @escaping (UIView, UIView, @escaping () -> Void) -> Void) -> AnimatorWrapperRenderNode { AnimatorWrapperRenderNode(content: self, passthroughUpdate: false, deleteBlock: deleteBlock) } } diff --git a/Sources/UIComponent/Documentation.docc/Animation.md b/Sources/UIComponent/Documentation.docc/Animation.md index c4edd392..f15ceb26 100644 --- a/Sources/UIComponent/Documentation.docc/Animation.md +++ b/Sources/UIComponent/Documentation.docc/Animation.md @@ -4,21 +4,21 @@ Learn how to animate view transitions ## Overview -UIComponent allows you to configure an ``Animator`` object to the ``CompoenentView`` or at the individual Component level to animate view transitions. Whenever a view is inserted, deleted, or its frame got updated, the animator will be called to perform the corresponding animation. +UIComponent allows you to configure an ``Animator`` object to the ``CompoenentEngine`` or at the individual Component level to animate view transitions. Whenever a view is inserted, deleted, or its frame got updated, the animator will be called to perform the corresponding animation. There is also a built-in ``Animator`` called ``TransformAnimator`` which you can use directly. ```swift -componentView.animator = TransformAnimator(transform: CATransform3DMakeScale(0.5, 0.5, 1), duration: 0.4) +view.componentEngine.animator = TransformAnimator(transform: CATransform3DMakeScale(0.5, 0.5, 1), duration: 0.4) ``` -Then whenever a cell is removed or inserted from the componentView, it will be animated with a scale transform. +Then whenever a cell is removed or inserted from the hosting view, it will be animated with a scale transform. TransformAnimator also performs update animation whenever the frame of a view changes. To configure the animator on the individual component level, use the ``Component/animator(_:)`` modifier. ```swift -componentView.component = VStack { +view.componentEngine.component = VStack { Text("Animated text").animator(TransformAnimator(transform: CATransform3DMakeTranslation(0, 100, 0), duration: 0.4)) Text("Not animated text") } @@ -39,20 +39,20 @@ You can also use the following modifiers to configure custom animation without c ```swift Text("Animated Insert/Delete/Update") - .animateInsert { componentView, view, frame in + .animateInsert { hostingView, view, frame in view.alpha = 0.0 UIView.animate(withDuration: 0.3) { view.alpha = 1.0 } } - .animateDelete { componentView, view, completion in + .animateDelete { hostingView, view, completion in UIView.animate(withDuration: 0.3) { view.alpha = 0.0 } completion: { _ in completion() } } - .animateUpdate { componentView, view, frame in + .animateUpdate { hostingView, view, frame in UIView.animate(withDuration: 0.3) { view.frame = frame } @@ -64,9 +64,9 @@ Text("Animated Insert/Delete/Update") To create a custom animator, implement the following 3 methods and perform the corresponding animation when needed. @Links(visualStyle: list) { -- ``Animator/insert(componentView:view:frame:)-9g90`` -- ``Animator/delete(componentView:view:completion:)-umbr`` -- ``Animator/update(componentView:view:frame:)-15tud`` +- ``Animator/insert(hostingView:view:frame:)-9g90`` +- ``Animator/delete(hostingView:view:completion:)-umbr`` +- ``Animator/update(hostingView:view:frame:)-15tud`` } Example fade animator: @@ -79,12 +79,12 @@ struct FadeAnimator: Animator { self.duration = duration } - func delete(componentView: ComponentDisplayableView, view: UIView, + func delete(hostingView: UIView, view: UIView, completion: @escaping () -> Void) { - if // Only animate when the componentView's component is updated, not when scrolling. - componentView.isReloading, + if // Only animate when the hostingView's component is updated, not when scrolling. + hostingView.isReloading, // only animate if the view is deleted visibly on screen. Drop the animation if the cell is not visible. - componentView.bounds.intersects(view.frame) { + hostingView.bounds.intersects(view.frame) { UIView.animate(withDuration: duration, delay: 0, options: [.allowUserInteraction], animations: { view.alpha = 0 }, completion: { _ in @@ -95,15 +95,15 @@ struct FadeAnimator: Animator { } } - func insert(componentView: ComponentDisplayableView, view: UIView, frame: CGRect) { + func insert(hostingView: UIView, view: UIView, frame: CGRect) { view.bounds.size = frame.bounds.size view.center = frame.center - if // Only animate when the componentView's component is updated, not when scrolling. - componentView.isReloading, + if // Only animate when the hostingView's component is updated, not when scrolling. + hostingView.isReloading, // don't animate the first reload - componentView.hasReloaded, + hostingView.hasReloaded, // only animate if the view is inserted visibly on screen. Drop the animation if the cell is not visible. - componentView.bounds.intersects(frame) + hostingView.bounds.intersects(frame) { view.alpha = 0 UIView.animate(withDuration: duration, delay: 0, options: [.allowUserInteraction], animations: { diff --git a/Sources/UIComponent/Documentation.docc/Architecture.md b/Sources/UIComponent/Documentation.docc/Architecture.md index fa0bf211..de679aa5 100644 --- a/Sources/UIComponent/Documentation.docc/Architecture.md +++ b/Sources/UIComponent/Documentation.docc/Architecture.md @@ -14,7 +14,12 @@ UIComponent has 3 main types of objects to render a UI. ``RenderNode`` is also a tree structure that holds UI informations like ``RenderNode/size``, ``RenderNode/children-85mp2``, and ``RenderNode/positions-34087``. -To render the UI, UIComponent will ask the `RenderNode` for a flat list of ``Renderable`` that should be displayed in the visible frame. +To render the UI, UIComponent will ask the `RenderNode` for a flat list of ``RenderNodeChild`` that should be displayed in the visible frame. + + +##### RenderNodeChild + +``RenderNodeChild`` is an intermediate tree structure that represents all of the render nodes that should be rendered onscreen. The framework will then use this information to generate a list of ``Renderable``s ##### Renderable @@ -26,35 +31,37 @@ To render the UI, UIComponent will ask the `RenderNode` for a flat list of ``Ren ### Reload The entire process from the **Component** to the **UIView** being rendered onscreen is called a **"reload"**. This happens when -* Assign a new ``ComponentView/component`` -* Manually calling ``ComponentView/setNeedsReload()`` or ``ComponentView/reloadData(contentOffsetAdjustFn:)`` -* Size of the ``ComponentView`` changes. -* SafeAreaInsets of the ``ComponentView`` changes. +* Assign a new ``ComponentEngine/component`` +* Manually calling ``ComponentEngine/setNeedsReload()`` or ``ComponentEngine/reloadData(contentOffsetAdjustFn:)`` +* Size of the hosting view changes. +* SafeAreaInsets of the hosting view changes. -During a reload, the ComponentView calls the **layout** method and cache the resulting ``RenderNode``. Then it will start the **render** process. +During a reload, the ComponentEngine calls the **layout** method and cache the resulting ``RenderNode``. Then it will start the **render** process. ### Render The process from the RenderNode to the **UIView** being rendered onscreen is called a **"render"**. This happens when * Scrolled to a new position (i.e. contentOffset changes) * During a reload. -* Manually calling ``ComponentView/setNeedsRender()`` +* Manually calling ``ComponentEngine/setNeedsRender()`` -The **render** process performs a **visibility test** by asking the RenderNode to provide a list of Renderables given visible frame using the `_visibleRenderables(in frame: CGRect)` method. +The **render** process performs a **visibility test** by asking the RenderNode to provide a list of children that are visible using the `visibleChildren(in frame: CGRect)` method. Each type of RenderNode might have a different visibility test implementation. -VStack's RenderNode for example, uses binary search to find a list of child RenderNodes that are visible in the provided frame. then it ask each visible child to provide its own list of Renderables. Finally, it merges all Renderable returned by its children into a flattened list. +``VStack``'s `RenderNode` for example, uses binary search to find a list of child RenderNodes that are visible in the provided frame. + +For a RenderNode that represents a view, it will just return an empty array. -For a RenderNode that represents a view, it will just return a single Renderable that represents the view that will be displayed. +The framework will then generates a list of ``Renderable``s from the resulting ``RenderNodeChild`` array. ### Display -With a list of Renderables, ComponentView then tries to **display** them onscreen. The ComponentView performs a diff algorithm that synchronize the subview list with the Renderable list. +With a list of Renderables, ComponentEngine then tries to **display** them onscreen. The ComponentEngine performs a diff algorithm that synchronize the subview list with the Renderable list. Each Renderable links to its corresponding RenderNode. If the view is not on screen yet, it will call the RenderNode's ``RenderNode/makeView()-6puft`` to generate a new UIView. If the view is already onscreen, it calls the ``RenderNode/updateView(_:)-2xjz4`` method to update the view to the latest state. -Renderable also contains the frame of the view that should be displayed onscreen. The ComponentView will use this frame to set the view's frame. +Renderable also contains the frame of the view that should be displayed onscreen. The ComponentEngine will use this frame to set the view's frame. Once the UIViews have been inserted to the view hierarchy, the entire process is finished. diff --git a/Sources/UIComponent/Documentation.docc/ComponentBasics.md b/Sources/UIComponent/Documentation.docc/ComponentBasics.md index 939c3c0a..629f096d 100644 --- a/Sources/UIComponent/Documentation.docc/ComponentBasics.md +++ b/Sources/UIComponent/Documentation.docc/ComponentBasics.md @@ -22,11 +22,10 @@ VStack(spacing: 8, alignItems: .center) { ![](ComponentBasics) -To render the ``Component`` on screen, You can use either the ``ComponentView`` or ``ComponentScrollView``, and assign your Component to the ``ComponentDisplayableView/component`` field. +To render the ``Component`` on a view, assign the component to the ``UIView.componentEngine.component`` property. The view will automatically reload and display the UI. ```swift -let componentView = ComponentView() -componentView.component = VStack(spacing: 8, alignItems: .center) { +view.componentEngine.component = VStack(spacing: 8, alignItems: .center) { Image("logo") Text("Hello World!") } diff --git a/Sources/UIComponent/Documentation.docc/CustomComponent.md b/Sources/UIComponent/Documentation.docc/CustomComponent.md index 9fb5c255..8d92666b 100644 --- a/Sources/UIComponent/Documentation.docc/CustomComponent.md +++ b/Sources/UIComponent/Documentation.docc/CustomComponent.md @@ -42,7 +42,7 @@ struct ProfileComponent: ComponentBuilder { } // Usage -componentView.component = ProfileComponent(profile: myProfile) +view.componentEngine.component = ProfileComponent(profile: myProfile) ``` ##### Custom Layout Component diff --git a/Sources/UIComponent/Documentation.docc/CustomView.md b/Sources/UIComponent/Documentation.docc/CustomView.md index d75f7fe8..6c616eb9 100644 --- a/Sources/UIComponent/Documentation.docc/CustomView.md +++ b/Sources/UIComponent/Documentation.docc/CustomView.md @@ -17,7 +17,7 @@ In fact, UIComponent treats all ``UIKit/UIView`` as components, so you can inser let myCustomView = MyCustomView() // place it in the Component hierarchy -componentView.component = VStack { +view.componentEngine.component = VStack { Text("Working with custom view") myCustomView } diff --git a/Version2MigrationGuide.md b/Sources/UIComponent/Documentation.docc/MigrationGuides/Version2MigrationGuide.md similarity index 100% rename from Version2MigrationGuide.md rename to Sources/UIComponent/Documentation.docc/MigrationGuides/Version2MigrationGuide.md diff --git a/Sources/UIComponent/Documentation.docc/MigrationGuides/Version4MigrationGuide.md b/Sources/UIComponent/Documentation.docc/MigrationGuides/Version4MigrationGuide.md new file mode 100644 index 00000000..f4e94a4a --- /dev/null +++ b/Sources/UIComponent/Documentation.docc/MigrationGuides/Version4MigrationGuide.md @@ -0,0 +1,44 @@ +# UIComponent Version 4.0 Migration Guide + +With version 4.0, UIComponent supports displaying `Component` on any `UIView`, not just `ComponentView` and `ComponentScrollView`. + +For example, before 4.0, you have to create an instance of `ComponentView` to display a Component +```swift +// before version 4.0 +override func viewDidLoad() { + super.viewDidLoad() + let componentView = ComponentView() + componentView.component = VStack { + Text("Hello World!") + } + view.addSubview(componentView) +} +override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + componentView.frame = view.bounds +} +``` + +```swift +// after version 4.0 +override func viewDidLoad() { + super.viewDidLoad() + view.componentEngine.component = VStack { + Text("Hello World!") + } +} +``` + +Use `view.componentEngine` to access the `component`, `animator`, `reloadData` or any other component related methods. + +The `ComponentView`, `ComponentScrollView`, and the `ComponentDisplayableView` are still supported, but deprecated in favor of using `view.componentEngine`. + +There are also many other changes in the API: +* `visibleIndexes` and `visibleRenderables` are now combined into a `visibleChildren` method that returns an array of `RenderNodeChild` instead of `Renderable`. The resulting `Renderable` array is generated by the framework from the `RenderNodeChild` array. +* A new method `adjustVisibleFrame` is added to `RenderNode` to adjust the visible frame of the render node before the visibility test. +* `ComponentViewComponent` is now `ViewWrapperComponent` +* `Animator`'s methods now receive `hostingView: UIView` instead of `componentView: ComponentDisplayableView` +* The built-in enviornment value of `currentComponentView` is now named `hostingView` +* `ComponentReloadDelegate` is now `ComponentEngineReloadDelegate` +* `TappableView` is now a `UIView` subclass instead of a `ComponentView` +* `PrimaryMenu` doesn't have a `contentView` anymore. Instead, the component is rendered directly on the `PrimaryMenu` view. diff --git a/Sources/UIComponent/Documentation.docc/PerformanceOptimization.md b/Sources/UIComponent/Documentation.docc/PerformanceOptimization.md index aafc6ddd..8376f659 100644 --- a/Sources/UIComponent/Documentation.docc/PerformanceOptimization.md +++ b/Sources/UIComponent/Documentation.docc/PerformanceOptimization.md @@ -67,11 +67,11 @@ VStack { You can also create a custom view that wraps your child component. This way the child component won't be constructed or layed out when reloading the list. ```swift -class ItemView: ComponentView { +class ItemView: UIView { var item: Item? { didSet { guard item != oldValue else { return } - component = VStack { + componentEngine.component = VStack { Image(item.image) Text(item.title) Text(item.subtitle) @@ -89,15 +89,15 @@ VStack { #### Async layout (Beta) UIComponent also provides an ``ComponentEngine/asyncLayout`` option. -This allows the ``ComponentView`` to run layout calculation on a background thread and free up the main UI thread. It should improve the framerate and scroll performance, but it does not improve the layout latency. (i.e. if you layout takes 2 seconds, it will still take 2 seconds to complete, but the user are able to scroll and interact with the app while the calculation is running) +This allows the ``ComponentEngine`` to run layout calculation on a background thread and free up the main UI thread. It should improve the framerate and scroll performance, but it does not improve the layout latency. (i.e. if you layout takes 2 seconds, it will still take 2 seconds to complete, but the user are able to scroll and interact with the app while the calculation is running) -To enabled async layout on a ``ComponentView``: +To enabled async layout on a ``ComponentEngine``: ```swift -componentView.engine.asyncLayout = true +view.componentEngine.asyncLayout = true ``` Keep in mind that doing layout on a background thread have a few implications, you should only consider this approach on a performance critical situation: -1. View hierarchy will not immediately reflect the Component state even if after calling ``ComponentView/reloadData(contentOffsetAdjustFn:)`` +1. View hierarchy will not immediately reflect the Component state even if after calling ``ComponentEngine/reloadData(contentOffsetAdjustFn:)`` 2. Your layout code inside your components must be thread safe. * Fortunately, most components are structs which are thread safe by default. 3. Your layout code must not access UI properties during layout. diff --git a/Sources/UIComponent/Documentation.docc/StateManagement.md b/Sources/UIComponent/Documentation.docc/StateManagement.md index 208a7dfc..2c35bde0 100644 --- a/Sources/UIComponent/Documentation.docc/StateManagement.md +++ b/Sources/UIComponent/Documentation.docc/StateManagement.md @@ -22,18 +22,12 @@ class MyViewController: UIViewController { reloadComponent() } } - let componentView = ComponentView() func viewDidLoad() { super.viewDidLoad() - view.addSubview(componentView) reloadComponent() } - func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - componentView.frame = view.bounds - } func reloadComponent() { - componentView.component = VStack { + view.componentEngine.component = VStack { Text("Count: \(viewModel.count)") Text("Increase").tappableView { [weak self] in self?.viewModel.count += 1 @@ -59,7 +53,7 @@ If you are worried about performance, there are are few optimization tricks that For complex UI, sometimes you don't want to propergate every action to the top level. If this is the case, we recommend creating a custom View that manages the local state. ```swift -class ProfileCell: ComponentView { +class ProfileCell: UIView { // external state var profile: Profile? { didSet { @@ -77,7 +71,7 @@ class ProfileCell: ComponentView { } func reloadComponent() { - component = VStack { + componentEngine.component = VStack { HStack { Image(profileImage ?? UIImage(named: "placeholder")!) .size(width: 50, height: 50) diff --git a/Tests/UIComponentTests/ReuseTest.swift b/Tests/UIComponentTests/ReuseTest.swift index 1c30dff4..12d00c85 100644 --- a/Tests/UIComponentTests/ReuseTest.swift +++ b/Tests/UIComponentTests/ReuseTest.swift @@ -6,10 +6,10 @@ import XCTest import UIKit final class ReuseTests: XCTestCase { - var componentView: ComponentView! + var view: UIView! override func setUp() { - componentView = ComponentView() - componentView.frame = CGRect(x: 0, y: 0, width: 500, height: 500) + view = UIView() + view.frame = CGRect(x: 0, y: 0, width: 500, height: 500) super.setUp() } @@ -18,18 +18,18 @@ final class ReuseTests: XCTestCase { } func testBasicReuse() { - componentView.component = Text("1") - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let existingLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("1") + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let existingLabel = view.subviews.first as? UILabel XCTAssertNotNil(existingLabel) XCTAssertEqual(existingLabel?.text, "1") - componentView.component = VStack { + view.componentEngine.component = VStack { Text("2") } - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let newLabel = componentView.subviews.first as? UILabel + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let newLabel = view.subviews.first as? UILabel XCTAssertNotNil(newLabel) XCTAssertEqual(newLabel?.text, "2") @@ -38,16 +38,16 @@ final class ReuseTests: XCTestCase { } func testNoReuseWhenReuseStrategyDiffers() { - componentView.component = Text("1").reuseStrategy(.key("myLabel")) - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let existingLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("1").reuseStrategy(.key("myLabel")) + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let existingLabel = view.subviews.first as? UILabel XCTAssertNotNil(existingLabel) XCTAssertEqual(existingLabel?.text, "1") - componentView.component = Text("2") - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let newLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("2") + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let newLabel = view.subviews.first as? UILabel XCTAssertNotNil(newLabel) XCTAssertEqual(newLabel?.text, "2") @@ -56,18 +56,18 @@ final class ReuseTests: XCTestCase { } func testNoReuseWhenNoReuseStrategy() { - componentView.component = Text("1").reuseStrategy(.noReuse) - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let existingLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("1").reuseStrategy(.noReuse) + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let existingLabel = view.subviews.first as? UILabel XCTAssertNotNil(existingLabel) XCTAssertEqual(existingLabel?.text, "1") - componentView.component = VStack { + view.componentEngine.component = VStack { Text("2").reuseStrategy(.noReuse) } - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let newLabel = componentView.subviews.first as? UILabel + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let newLabel = view.subviews.first as? UILabel XCTAssertNotNil(newLabel) XCTAssertEqual(newLabel?.text, "2") @@ -76,18 +76,18 @@ final class ReuseTests: XCTestCase { } func testNoReuseWhenNoReuseStrategyWithAnyComponentOfView() { - componentView.component = Text("1").eraseToAnyComponentOfView().reuseStrategy(.noReuse) - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let existingLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("1").eraseToAnyComponentOfView().reuseStrategy(.noReuse) + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let existingLabel = view.subviews.first as? UILabel XCTAssertNotNil(existingLabel) XCTAssertEqual(existingLabel?.text, "1") - componentView.component = VStack { + view.componentEngine.component = VStack { Text("2").eraseToAnyComponentOfView().reuseStrategy(.noReuse) } - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let newLabel = componentView.subviews.first as? UILabel + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let newLabel = view.subviews.first as? UILabel XCTAssertNotNil(newLabel) XCTAssertEqual(newLabel?.text, "2") @@ -96,18 +96,18 @@ final class ReuseTests: XCTestCase { } func testNoReuseWhenNoReuseStrategyWithAnyComponent() { - componentView.component = Text("1").eraseToAnyComponent().reuseStrategy(.noReuse) - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let existingLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("1").eraseToAnyComponent().reuseStrategy(.noReuse) + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let existingLabel = view.subviews.first as? UILabel XCTAssertNotNil(existingLabel) XCTAssertEqual(existingLabel?.text, "1") - componentView.component = VStack { + view.componentEngine.component = VStack { Text("2").eraseToAnyComponent().reuseStrategy(.noReuse) } - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let newLabel = componentView.subviews.first as? UILabel + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let newLabel = view.subviews.first as? UILabel XCTAssertNotNil(newLabel) XCTAssertEqual(newLabel?.text, "2") @@ -116,16 +116,16 @@ final class ReuseTests: XCTestCase { } func testReuseWithSameAttributes() { - componentView.component = Text("1").backgroundColor(.red) - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let existingLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("1").backgroundColor(.red) + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let existingLabel = view.subviews.first as? UILabel XCTAssertNotNil(existingLabel) XCTAssertEqual(existingLabel?.text, "1") - componentView.component = Text("2").backgroundColor(.blue) - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let newLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("2").backgroundColor(.blue) + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let newLabel = view.subviews.first as? UILabel XCTAssertNotNil(newLabel) XCTAssertEqual(newLabel?.text, "2") @@ -134,17 +134,17 @@ final class ReuseTests: XCTestCase { } func testNoReuseWithDifferentAttributes() { - componentView.component = Text("1").textColor(.red) - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let existingLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("1").textColor(.red) + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let existingLabel = view.subviews.first as? UILabel XCTAssertNotNil(existingLabel) XCTAssertEqual(existingLabel?.text, "1") XCTAssertEqual(existingLabel?.textColor, .red) - componentView.component = Text("2") - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let newLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("2") + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let newLabel = view.subviews.first as? UILabel XCTAssertNotNil(newLabel) XCTAssertEqual(newLabel?.text, "2") @@ -153,17 +153,17 @@ final class ReuseTests: XCTestCase { } func testNoReuseWithDifferentAttributesAndAnyComponentOfView() { - componentView.component = Text("1").textColor(.red).eraseToAnyComponentOfView() - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let existingLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("1").textColor(.red).eraseToAnyComponentOfView() + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let existingLabel = view.subviews.first as? UILabel XCTAssertNotNil(existingLabel) XCTAssertEqual(existingLabel?.text, "1") XCTAssertEqual(existingLabel?.textColor, .red) - componentView.component = Text("2").eraseToAnyComponentOfView() - componentView.reloadData() - XCTAssertEqual(componentView.subviews.count, 1) - let newLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("2").eraseToAnyComponentOfView() + view.componentEngine.reloadData() + XCTAssertEqual(view.subviews.count, 1) + let newLabel = view.subviews.first as? UILabel XCTAssertNotNil(newLabel) XCTAssertEqual(newLabel?.text, "2") @@ -172,19 +172,19 @@ final class ReuseTests: XCTestCase { } func testNoReuseWithSameAttributes() { - componentView.component = Text("1").reuseStrategy(.noReuse).textColor(.red).id("1") - componentView.reloadData() - XCTAssertEqual(componentView.renderNode?.reuseStrategy, .noReuse) - XCTAssertEqual(componentView.subviews.count, 1) - let existingLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("1").reuseStrategy(.noReuse).textColor(.red).id("1") + view.componentEngine.reloadData() + XCTAssertEqual(view.componentEngine.renderNode?.reuseStrategy, .noReuse) + XCTAssertEqual(view.subviews.count, 1) + let existingLabel = view.subviews.first as? UILabel XCTAssertNotNil(existingLabel) XCTAssertEqual(existingLabel?.text, "1") XCTAssertEqual(existingLabel?.textColor, .red) - componentView.component = Text("2").reuseStrategy(.noReuse).textColor(.red).id("2") - componentView.reloadData() - XCTAssertEqual(componentView.renderNode?.reuseStrategy, .noReuse) - XCTAssertEqual(componentView.subviews.count, 1) - let newLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("2").reuseStrategy(.noReuse).textColor(.red).id("2") + view.componentEngine.reloadData() + XCTAssertEqual(view.componentEngine.renderNode?.reuseStrategy, .noReuse) + XCTAssertEqual(view.subviews.count, 1) + let newLabel = view.subviews.first as? UILabel XCTAssertNotNil(newLabel) XCTAssertEqual(newLabel?.text, "2") @@ -197,20 +197,20 @@ final class ReuseTests: XCTestCase { label1.text = "1" let label2 = UILabel() label2.text = "2" - componentView.component = ViewComponent(view: label1).textColor(.red) - componentView.reloadData() - XCTAssertEqual(componentView.renderNode?.reuseStrategy, .noReuse) - XCTAssertEqual(componentView.subviews.count, 1) - let existingLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = ViewComponent(view: label1).textColor(.red) + view.componentEngine.reloadData() + XCTAssertEqual(view.componentEngine.renderNode?.reuseStrategy, .noReuse) + XCTAssertEqual(view.subviews.count, 1) + let existingLabel = view.subviews.first as? UILabel XCTAssertNotNil(existingLabel) XCTAssertEqual(existingLabel?.text, "1") XCTAssertEqual(existingLabel?.textColor, .red) XCTAssertEqual(existingLabel, label1) - componentView.component = ViewComponent(view: label2).textColor(.red) - componentView.reloadData() - XCTAssertEqual(componentView.renderNode?.reuseStrategy, .noReuse) - XCTAssertEqual(componentView.subviews.count, 1) - let newLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = ViewComponent(view: label2).textColor(.red) + view.componentEngine.reloadData() + XCTAssertEqual(view.componentEngine.renderNode?.reuseStrategy, .noReuse) + XCTAssertEqual(view.subviews.count, 1) + let newLabel = view.subviews.first as? UILabel XCTAssertNotNil(newLabel) XCTAssertEqual(newLabel?.text, "2") XCTAssertEqual(newLabel?.textColor, .red) @@ -219,19 +219,19 @@ final class ReuseTests: XCTestCase { } func testStructureIdentity() { - componentView.component = Text("1").reuseStrategy(.noReuse).textColor(.red) - componentView.reloadData() - XCTAssertEqual(componentView.renderNode?.reuseStrategy, .noReuse) - XCTAssertEqual(componentView.subviews.count, 1) - let existingLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("1").reuseStrategy(.noReuse).textColor(.red) + view.componentEngine.reloadData() + XCTAssertEqual(view.componentEngine.renderNode?.reuseStrategy, .noReuse) + XCTAssertEqual(view.subviews.count, 1) + let existingLabel = view.subviews.first as? UILabel XCTAssertNotNil(existingLabel) XCTAssertEqual(existingLabel?.text, "1") XCTAssertEqual(existingLabel?.textColor, .red) - componentView.component = Text("2").reuseStrategy(.noReuse).textColor(.red) - componentView.reloadData() - XCTAssertEqual(componentView.renderNode?.reuseStrategy, .noReuse) - XCTAssertEqual(componentView.subviews.count, 1) - let newLabel = componentView.subviews.first as? UILabel + view.componentEngine.component = Text("2").reuseStrategy(.noReuse).textColor(.red) + view.componentEngine.reloadData() + XCTAssertEqual(view.componentEngine.renderNode?.reuseStrategy, .noReuse) + XCTAssertEqual(view.subviews.count, 1) + let newLabel = view.subviews.first as? UILabel XCTAssertNotNil(newLabel) XCTAssertEqual(newLabel?.text, "2") diff --git a/Tests/UIComponentTests/UIComponentTests.swift b/Tests/UIComponentTests/UIComponentTests.swift index d2813bc1..52963ca9 100644 --- a/Tests/UIComponentTests/UIComponentTests.swift +++ b/Tests/UIComponentTests/UIComponentTests.swift @@ -11,64 +11,64 @@ let maxSize = CGSize(width: 100, height: CGFloat.infinity) final class UIComponentTests: XCTestCase { func testPerfHStackText() { - let componentView = ComponentView() + let view = UIView() measure { - componentView.component = HStack { + view.componentEngine.component = HStack { for _ in 0..<10000 { Text("Test") } } - componentView.frame = CGRect(x: 0, y: 0, width: 300, height: 600) - componentView.layoutIfNeeded() + view.frame = CGRect(x: 0, y: 0, width: 300, height: 600) + view.layoutIfNeeded() } } func testVisibleInsets() { - let componentView = ComponentView() - componentView.component = VStack(spacing: 100) { + let view = UIView() + view.componentEngine.component = VStack(spacing: 100) { Text(text1).size(width: 300, height: 300) Text(text2).size(width: 300, height: 300) }.inset(100).visibleInset(-100) - componentView.frame = CGRect(x: 0, y: 0, width: 500, height: 400) - componentView.layoutIfNeeded() - XCTAssertEqual(componentView.engine.visibleRenderable.count, 1) - componentView.frame = CGRect(x: 0, y: 0, width: 500, height: 500) - componentView.layoutIfNeeded() - XCTAssertEqual(componentView.engine.visibleRenderable.count, 2) + view.frame = CGRect(x: 0, y: 0, width: 500, height: 400) + view.layoutIfNeeded() + XCTAssertEqual(view.componentEngine.visibleRenderables.count, 1) + view.frame = CGRect(x: 0, y: 0, width: 500, height: 500) + view.layoutIfNeeded() + XCTAssertEqual(view.componentEngine.visibleRenderables.count, 2) } func testOffsetVisibility() { // Offset shouldn't adjust visibility, it should use the original frame for visibility testing - let componentView = ComponentView() - componentView.component = ZStack { + let view = UIView() + view.componentEngine.component = ZStack { Text(text1).size(width: 300, height: 300).offset(CGPoint(x: 0, y: 300)) } - componentView.bounds = CGRect(x: 0, y: 0, width: 300, height: 300) - componentView.layoutIfNeeded() - XCTAssertEqual(componentView.engine.visibleRenderable.count, 1) + view.bounds = CGRect(x: 0, y: 0, width: 300, height: 300) + view.layoutIfNeeded() + XCTAssertEqual(view.componentEngine.visibleRenderables.count, 1) - componentView.bounds = CGRect(x: 0, y: 300, width: 300, height: 300) - componentView.layoutIfNeeded() - XCTAssertEqual(componentView.engine.visibleRenderable.count, 0) + view.bounds = CGRect(x: 0, y: 300, width: 300, height: 300) + view.layoutIfNeeded() + XCTAssertEqual(view.componentEngine.visibleRenderables.count, 0) } /// Test to make sure component with fixed size are /// not being layouted when not visible func testLazyLayout() { - let componentView = ComponentView() - componentView.component = VStack { + let view = UIView() + view.componentEngine.component = VStack { Text(text1).size(width: 300, height: 600) Text(text2).size(width: 300, height: 600) } - componentView.frame = CGRect(x: 0, y: 0, width: 300, height: 600) - componentView.layoutIfNeeded() - let vRenderNode = componentView.engine.renderNode as? VerticalRenderNode + view.frame = CGRect(x: 0, y: 0, width: 300, height: 600) + view.layoutIfNeeded() + let vRenderNode = view.componentEngine.renderNode as? VerticalRenderNode XCTAssertNotNil(vRenderNode) let firstText = vRenderNode!.children[0] as? AnyRenderNodeOfView let secondText = vRenderNode!.children[1] as? AnyRenderNodeOfView XCTAssertNotNil(firstText) XCTAssertNotNil(secondText) - XCTAssertEqual(componentView.engine.visibleRenderable.count, 1) + XCTAssertEqual(view.componentEngine.visibleRenderables.count, 1) let lazyNode1 = firstText!.erasing as? LazyRenderNode let lazyNode2 = secondText!.erasing as? LazyRenderNode XCTAssertEqual(lazyNode1!.didLayout, true) @@ -76,48 +76,48 @@ final class UIComponentTests: XCTestCase { } /// Test to make sure environment is passed down to lazy layout even when layout is performed later func testLazyLayoutEnvironment() { - let componentView = ComponentView() - var text1ComponentView: ComponentDisplayableView? - var text2ComponentView: ComponentDisplayableView? - componentView.component = VStack { + let view = UIView() + var text1HostingView: UIView? + var text2HostingView: UIView? + view.componentEngine.component = VStack { ConstraintReader { _ in - text1ComponentView = Environment(\.currentComponentView).wrappedValue + text1HostingView = Environment(\.hostingView).wrappedValue return Text(text1) }.size(width: 300, height: 600) ConstraintReader { _ in - text2ComponentView = Environment(\.currentComponentView).wrappedValue + text2HostingView = Environment(\.hostingView).wrappedValue return Text(text2) }.size(width: 300, height: 600) } - componentView.bounds = CGRect(x: 0, y: 0, width: 300, height: 600) - componentView.layoutIfNeeded() - XCTAssertIdentical(text1ComponentView, componentView) - XCTAssertNil(text2ComponentView) - componentView.bounds = CGRect(x: 0, y: 10, width: 300, height: 600) - componentView.layoutIfNeeded() - XCTAssertIdentical(text1ComponentView, componentView) - XCTAssertIdentical(text2ComponentView, componentView) + view.bounds = CGRect(x: 0, y: 0, width: 300, height: 600) + view.layoutIfNeeded() + XCTAssertIdentical(text1HostingView, view) + XCTAssertNil(text2HostingView) + view.bounds = CGRect(x: 0, y: 10, width: 300, height: 600) + view.layoutIfNeeded() + XCTAssertIdentical(text1HostingView, view) + XCTAssertIdentical(text2HostingView, view) } /// Test to make sure weak environment value is correctly release even when captured by a lazy layout func testLazyLayoutWeakEnvironment() { - var componentView: ComponentView? = ComponentView() - weak var componentView2 = componentView - weak var text1ComponentView: ComponentDisplayableView? - componentView?.component = VStack { + var view: UIView? = UIView() + weak var view2 = view + weak var text1HostingView: UIView? + view?.componentEngine.component = VStack { ConstraintReader { _ in - text1ComponentView = Environment(\.currentComponentView).wrappedValue + text1HostingView = Environment(\.hostingView).wrappedValue return Text(text1) }.size(width: 300, height: 600) Text(text2).size(width: 300, height: 600) } - componentView?.bounds = CGRect(x: 0, y: 0, width: 300, height: 600) - componentView?.layoutIfNeeded() - XCTAssertNotNil(componentView2) - XCTAssertNotNil(text1ComponentView) - XCTAssertIdentical(text1ComponentView, componentView) - componentView = nil - XCTAssertNil(text1ComponentView) - XCTAssertNil(componentView2) + view?.bounds = CGRect(x: 0, y: 0, width: 300, height: 600) + view?.layoutIfNeeded() + XCTAssertNotNil(view2) + XCTAssertNotNil(text1HostingView) + XCTAssertIdentical(text1HostingView, view) + view = nil + XCTAssertNil(text1HostingView) + XCTAssertNil(view2) } func testLazyLayoutPerf() { let rawLayoutTime = measureTime { @@ -139,11 +139,11 @@ final class UIComponentTests: XCTestCase { XCTAssertLessThan(fixedSizeLayoutTime * 2, rawLayoutTime) } func measureTime(_ component: () -> any Component) -> TimeInterval { - let componentView = ComponentView() + let view = UIView() let startTime = CACurrentMediaTime() - componentView.component = component() - componentView.frame = CGRect(x: 0, y: 0, width: 300, height: 600) - componentView.layoutIfNeeded() + view.componentEngine.component = component() + view.frame = CGRect(x: 0, y: 0, width: 300, height: 600) + view.layoutIfNeeded() return CACurrentMediaTime() - startTime } func testPerfTextLayout() {