From e15f3f56088b3d0fa8277bf12004e67cfeee9eb2 Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Wed, 30 Sep 2026 20:30:39 -0700 Subject: [PATCH 1/2] [scroll] replace ScrollViewType with UIKit's scroll view names on ScrollView `ScrollViewType` reused names `NSScrollView` already has (`bounds`, `contentSize`, `contentView`) as same-named methods, which gave each name two meanings on AppKit. For `contentView()` it was a trap: Swift imports a `contentView() -> NSView?` onto every `NSScrollView` from `NSTextFinderBarContainer`, so in an optional or inferred context a call resolved to AppKit's clip view instead of the document view (#27). The protocol added nothing either: `ScrollView` is its superclass constraint and conforms itself, so no other type can conform. `ScrollView` now has UIKit's names and meanings on both platforms. On AppKit it implements `contentOffset`, `contentInset`, `adjustedContentInset`, `visibleSize` and `isScrollEnabled` on top of the clip view, and keeps its `contentSize` override, which AppKit itself never reads. The scroll range helpers move to `ScrollView+Scrolling.swift`, and the renderable container becomes the internal `contentContainerView`, which also replaces the public `documentView()`. Removed: `ScrollViewType`, `bounds()`, `setBounds(_:)`, `contentSize()`, `setContentSize(_:)`, `contentInsets()`, `setContentInsets(_:)`, `contentOffset()`, `setContentOffset(_:)`, `contentView()`, the macOS `documentView()`, and `BaseScrollView.isScrollable`. Fixes #27. --- CHANGELOG.md | 2 + .../RenderableTransition+Slide.swift | 8 +- .../ComposeView/ComposeView+Debug.swift | 2 +- .../ComposeView/ComposeView+ZOrder.swift | 2 +- .../ComposeUI/ComposeView/ComposeView.swift | 24 +- .../ScrollView/BaseScrollView.swift | 40 --- .../ScrollView/ScrollView+Scrolling.swift | 92 ++++++ .../CrossPlatform/ScrollView/ScrollView.swift | 85 +++++- .../ScrollView/ScrollViewType.swift | 251 ----------------- .../ComposeNodes/ButtonNodeTests.swift | 6 +- .../ComposeNodes/ColorNodeTests.swift | 4 +- .../ComposeViewNode+AnimationTests.swift | 12 +- .../ComposeViewNode+ParentResizeTests.swift | 18 +- .../ComposeNodes/ComposeViewNodeTests.swift | 26 +- .../ComposeNodes/DropShadowNodeTests.swift | 8 +- .../GestureRecognizerNodeTests.swift | 6 +- .../ComposeNodes/InnerShadowNodeTests.swift | 8 +- .../ComposeNodes/LabelNodeTests.swift | 2 +- .../ComposeNodes/LayerNodeTests.swift | 12 +- ...iftUIViewNode+ContentEvaluationTests.swift | 18 +- .../ComposeNodes/TextNodeTests.swift | 2 +- .../ComposeNodes/ViewNodeTests.swift | 8 +- .../ComposeView+AnimationBehaviorTests.swift | 34 +-- .../ComposeView+AnimationDecisionTests.swift | 8 +- .../ComposeView+CachedLayoutTests.swift | 6 +- ...omposeView+ContentUpdateContextTests.swift | 6 +- .../ComposeView+PreparedContentTests.swift | 20 +- .../ComposeView+ReentrantRefreshTests.swift | 4 +- .../ComposeView+RefreshTests.swift | 8 +- .../ComposeView+RenderBoundsTests.swift | 71 ++--- .../ComposeView+RenderFrameUpdateTests.swift | 14 +- .../ComposeView+RenderHandlerTests.swift | 34 ++- .../ComposeView+RenderReuseTests.swift | 12 +- .../ComposeView+RenderableTests.swift | 8 +- ...poseView+RenderableUpdateBoundsTests.swift | 30 +- .../ComposeView+ScrollBehaviorTests.swift | 42 +-- .../ComposeView/ComposeView+ZOrderTests.swift | 12 +- .../ComposeView/ComposeViewTests.swift | 6 +- ....swift => ScrollView+ScrollingTests.swift} | 37 +-- .../ScrollView/ScrollViewTests.swift | 264 +++++++++++++++++- .../ModifierPerformanceTests.swift | 2 +- .../Performance/RenderPerformanceTests.swift | 18 +- 42 files changed, 690 insertions(+), 582 deletions(-) create mode 100644 ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift delete mode 100644 ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollViewType.swift rename ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/{ScrollViewTypeTests.swift => ScrollView+ScrollingTests.swift} (68%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03dd5440..cd957bf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - Zero-duration transitions now call their completion, and a completion is also called when its animation is torn down early. - `RenderableTransition.opacity`'s `to` is now optional, and without it the fade ends at the opacity the content sets, for example with the `opacity` modifier, instead of 1. - `ComposeView.tile()` is now final on macOS. The content lays out for the view's bounds inside the border whether or not scroll bars show, so tiling that takes space from the clip view would cover the content. Place accessory views outside the `ComposeView` instead. +- Removed `ScrollViewType`. `ScrollView` now has UIKit's scroll view API on macOS too: `contentOffset` replaces `contentOffset()` and `setContentOffset(_:)`, `contentSize` replaces `contentSize()` and `setContentSize(_:)`, `adjustedContentInset` replaces `contentInsets()`, `contentInset` replaces `setContentInsets(_:)`, and `contentOffset` with `visibleSize` replace `bounds()`. Replace `setBounds(_:)` by setting `frame.size`, then `contentOffset`. `contentView()` and the macOS `documentView()` are no longer public: on macOS, `contentView()` shared its name with AppKit's `NSScrollView.contentView()`, so a call in an optional or inferred context returned the clip view instead of the document view. Use `documentView` on macOS, and the scroll view itself on iOS, tvOS and visionOS. +- Renamed `BaseScrollView.isScrollable` to UIKit's `isScrollEnabled`, which `ScrollView` now has on macOS too, and the debug event `renderDidUpdateScrollableBehavior(isScrollable:alwaysBounceHorizontal:alwaysBounceVertical:)` to `renderDidUpdateScrollableBehavior(isScrollEnabled:alwaysBounceHorizontal:alwaysBounceVertical:)`. ### Changes diff --git a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift index 924e3889..55e7718f 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeNode/RenderItem/RenderableTransition+Slide.swift @@ -93,11 +93,11 @@ public extension RenderableTransition { case .top: startFrame = targetFrame.translate(dy: -targetFrame.maxY - overshoot) case .bottom: - startFrame = targetFrame.translate(dy: context.contentView.bounds().height - targetFrame.minY + overshoot) + startFrame = targetFrame.translate(dy: context.contentView.visibleSize.height - targetFrame.minY + overshoot) case .left: startFrame = targetFrame.translate(dx: -targetFrame.maxX - overshoot) case .right: - startFrame = targetFrame.translate(dx: context.contentView.bounds().width - targetFrame.minX + overshoot) + startFrame = targetFrame.translate(dx: context.contentView.visibleSize.width - targetFrame.minX + overshoot) } startPosition = layer.position(from: startFrame) } @@ -130,11 +130,11 @@ public extension RenderableTransition { case .top: targetFrame = currentFrame.translate(dy: -currentFrame.maxY - overshoot) case .bottom: - targetFrame = currentFrame.translate(dy: context.contentView.bounds().height - currentFrame.minY + overshoot) + targetFrame = currentFrame.translate(dy: context.contentView.visibleSize.height - currentFrame.minY + overshoot) case .left: targetFrame = currentFrame.translate(dx: -currentFrame.maxX - overshoot) case .right: - targetFrame = currentFrame.translate(dx: context.contentView.bounds().width - currentFrame.minX + overshoot) + targetFrame = currentFrame.translate(dx: context.contentView.visibleSize.width - currentFrame.minX + overshoot) } guard timing.timing.duration > 0 || timing.delay > 0 else { diff --git a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+Debug.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+Debug.swift index 91ab0b4d..a78dec32 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+Debug.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+Debug.swift @@ -55,7 +55,7 @@ public extension ComposeView { case renderDidReceiveRenderableItems(renderableItems: [RenderableItem], contentSize: CGSize) /// The scrollable behavior has been updated. - case renderDidUpdateScrollableBehavior(isScrollable: Bool, alwaysBounceHorizontal: Bool, alwaysBounceVertical: Bool) + case renderDidUpdateScrollableBehavior(isScrollEnabled: Bool, alwaysBounceHorizontal: Bool, alwaysBounceVertical: Bool) /// The clipping behavior has been updated. case renderDidUpdateClippingBehavior(clipsToBounds: Bool) diff --git a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+ZOrder.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+ZOrder.swift index 4a67ec44..25ee836c 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+ZOrder.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+ZOrder.swift @@ -147,7 +147,7 @@ extension ComposeView { /// - renderableItemIds: The ids of the renderables being rendered, in z-order (back to front). /// - renderableMap: The map of the renderables being rendered, keyed by id. func placeNewRenderables(reusingIds: Set, renderableItemIds: [ComposeNodeId], renderableMap: [ComposeNodeId: Renderable]) { - let parent: View = contentView() + let parent = contentContainerView // the next view sibling (already placed correctly), while walking from front to back var nextViewSibling: View? diff --git a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift index dcdafdda..8796f3c4 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift @@ -481,7 +481,7 @@ open class ComposeView: BaseScrollView { /// The view is scrollable if the content is larger than the view's bounds. Otherwise, the view is not scrollable. case auto - /// The view does not modify scroll settings. `isScrollable` and `alwaysBounceHorizontal`/`alwaysBounceVertical` are managed by you. + /// The view does not modify scroll settings. `isScrollEnabled` and `alwaysBounceHorizontal`/`alwaysBounceVertical` are managed by you. case manual /// The view is always scrollable. The view will always bounce. @@ -1000,33 +1000,33 @@ open class ComposeView: BaseScrollView { } // set content size - setContentSize(roundedContentSize) + self.contentSize = roundedContentSize // update scrollable behavior switch scrollBehavior { case .auto: - isScrollable = overflowsHorizontally || overflowsVertically + isScrollEnabled = overflowsHorizontally || overflowsVertically alwaysBounceHorizontal = false alwaysBounceVertical = false case .manual: break case .always: - isScrollable = true + isScrollEnabled = true alwaysBounceHorizontal = true alwaysBounceVertical = true case .never: - isScrollable = false + isScrollEnabled = false alwaysBounceHorizontal = false alwaysBounceVertical = false } #if DEBUG - debug?.onEvent(.renderDidUpdateScrollableBehavior(isScrollable: isScrollable, alwaysBounceHorizontal: alwaysBounceHorizontal, alwaysBounceVertical: alwaysBounceVertical)) + debug?.onEvent(.renderDidUpdateScrollableBehavior(isScrollEnabled: isScrollEnabled, alwaysBounceHorizontal: alwaysBounceHorizontal, alwaysBounceVertical: alwaysBounceVertical)) #endif switch clippingBehavior { case .auto: - clipsToBounds = isScrollable + clipsToBounds = isScrollEnabled case .manual: break case .always: @@ -1074,7 +1074,7 @@ open class ComposeView: BaseScrollView { // updating the content size or the scroll indicators can move the scroll offset: on AppKit, hiding a legacy // scroller grows the clip view, which can clamp the offset. so read the offset after both, to render the viewport // the view ends up with. - bounds.origin = contentOffset() + bounds.origin = contentOffset if let willRenderHandler { willRenderHandler(self, WillRenderContext(contentSize: roundedContentSize, renderBounds: bounds, renderType: context.renderType(bounds: bounds))) @@ -1420,7 +1420,7 @@ open class ComposeView: BaseScrollView { renderableItem.willUpdate?(renderable, renderableUpdateContext) - renderable.addToParent(contentView()) + renderable.addToParent(contentContainerView) renderable.assertIdentityTransform() renderable.setFrame(newFrame) @@ -1549,7 +1549,7 @@ open class ComposeView: BaseScrollView { /// Returns the bounds used for layout and rendering. private func renderBounds() -> CGRect { #if canImport(AppKit) - // a legacy scroller shrinks the clip view (`bounds()`), so the size comes from the view's bounds inside the border, + // a legacy scroller shrinks the clip view (`visibleSize`), so the size comes from the view's bounds inside the border, // where AppKit tiles the clip view without scrollers. it then depends only on the bounds, the border, and the // magnification, so showing or hiding a scroller never changes the layout. let borderWidth: CGFloat @@ -1571,11 +1571,11 @@ open class ComposeView: BaseScrollView { let pixelsPerUnitY = hypot(unitY.width, unitY.height) let width = (max(self.bounds.width - 2 * borderWidth, 0) * pixelsPerUnitX).rounded() / pixelsPerUnitX let height = (max(self.bounds.height - 2 * borderWidth, 0) * pixelsPerUnitY).rounded() / pixelsPerUnitY - return CGRect(origin: bounds().origin, size: CGSize(width: width / magnification, height: height / magnification)) + return CGRect(origin: contentOffset, size: CGSize(width: width / magnification, height: height / magnification)) #endif #if canImport(UIKit) - return bounds() + return bounds #endif } diff --git a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/BaseScrollView.swift b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/BaseScrollView.swift index 87db5578..d3ac5245 100644 --- a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/BaseScrollView.swift +++ b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/BaseScrollView.swift @@ -105,46 +105,6 @@ open class BaseScrollView: ScrollView { } #endif - // MARK: - Scroll - - /// Whether the scroll view is scrollable. - public var isScrollable: Bool { - get { - #if canImport(UIKit) - return isScrollEnabled - #endif - - #if canImport(AppKit) - return _isScrollable - #endif - } - set { - #if canImport(UIKit) - isScrollEnabled = newValue - #endif - - #if canImport(AppKit) - _isScrollable = newValue - #endif - } - } - - #if canImport(AppKit) - private var _isScrollable: Bool = true - - override open func scrollWheel(with event: NSEvent) { - // https://apptyrant.com/2015/05/18/how-to-disable-nsscrollview-scrolling/ - guard _isScrollable else { - // send the event to outside of the scroll view. - // https://github.com/onmyway133/blog/issues/733 - nextResponder?.scrollWheel(with: event) - return - } - - super.scrollWheel(with: event) - } - #endif - // MARK: - Theme /// A publisher that emits the theme of the view. diff --git a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift new file mode 100644 index 00000000..dfd4cd98 --- /dev/null +++ b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift @@ -0,0 +1,92 @@ +// +// ScrollView+Scrolling.swift +// ComposéUI +// +// Created by Honghao Zhang on 9/30/26. +// Copyright © 2024 Honghao Zhang. +// +// MIT License +// +// Copyright (c) 2024 Honghao Zhang (github.com/honghaoz) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// + +#if canImport(AppKit) +import AppKit +#endif + +#if canImport(UIKit) +import UIKit +#endif + +public extension ScrollView { + + /// The minimum horizontal offset that the scroll view can scroll to, without elastic effect. + var minOffsetX: CGFloat { + -adjustedContentInset.left + } + + /// The maximum horizontal offset that the scroll view can scroll to, without elastic effect. + var maxOffsetX: CGFloat { + contentSize.width - visibleSize.width + adjustedContentInset.right + } + + /// The minimum vertical offset that the scroll view can scroll to, without elastic effect. + var minOffsetY: CGFloat { + -adjustedContentInset.top + } + + /// The maximum vertical offset that the scroll view can scroll to, without elastic effect. + var maxOffsetY: CGFloat { + contentSize.height - visibleSize.height + adjustedContentInset.bottom + } + + /// Whether the scroll view can scroll to the left. + var canScrollToLeft: Bool { + contentOffset.x > minOffsetX + } + + /// Whether the scroll view can scroll to the right. + var canScrollToRight: Bool { + contentOffset.x < maxOffsetX + } + + /// Whether the scroll view can scroll to the top. + var canScrollToTop: Bool { + contentOffset.y > minOffsetY + } + + /// Whether the scroll view can scroll to the bottom. + var canScrollToBottom: Bool { + contentOffset.y < maxOffsetY + } + + #if canImport(UIKit) + /// Stops any ongoing scroll deceleration, freezing the scroll view at its current position. + func stopDecelerating() { + setContentOffset(contentOffset, animated: false) + } + #endif +} + +// TODO: clarify setContentOffset with animation or not +// we need to clean API for setting content offset with/without animation +// it seems like UIKit's `contentOffset = " will adjust the offset without affecting live animations +// while `self.setContentOffset(..., animated:)` can set offset and stop the live animations diff --git a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift index 629e65ea..0ecde6cc 100644 --- a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift +++ b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift @@ -28,6 +28,8 @@ // IN THE SOFTWARE. // +// MARK: - AppKit + #if canImport(AppKit) import AppKit @@ -40,14 +42,50 @@ open class ScrollView: NSScrollView { override open var contentSize: CGSize { get { - documentView().bounds.size + contentContainerView.bounds.size + } + set { + ComposeUI.assert(contentContainerView.frame.origin == .zero) + contentContainerView.frame = CGRect(origin: .zero, size: newValue) + } + } + + /// The offset of the visible area's origin from the content's origin, like `UIScrollView`'s `contentOffset`. + /// + /// Unlike on UIKit, setting it keeps the offset within the scrollable range. + public var contentOffset: CGPoint { + get { + contentView.bounds.origin + } + set { + contentView.scroll(newValue) + } + } + + /// The custom distance that the content is inset from the scroll view's edges, like `UIScrollView`'s `contentInset`. + /// + /// This is `contentInsets`, which includes the automatic adjustments while `automaticallyAdjustsContentInsets` is on. + public var contentInset: EdgeInsets { + get { + contentInsets } set { - ComposeUI.assert(documentView().frame.origin == .zero) - documentView().frame = CGRect(origin: .zero, size: newValue) + contentInsets = newValue } } + /// The insets in effect, including the automatic adjustments, like `UIScrollView`'s `adjustedContentInset`. + /// + /// AppKit applies the automatic adjustments to `contentInsets` itself, so this is `contentInsets`. + public var adjustedContentInset: EdgeInsets { + contentInsets + } + + /// The size of the visible area in content coordinates, like `UIScrollView`'s `visibleSize`. + public var visibleSize: CGSize { + contentView.bounds.size + } + override public init(frame: CGRect) { super.init(frame: frame) @@ -110,19 +148,24 @@ open class ScrollView: NSScrollView { contentView.postsBoundsChangedNotifications = false } - // MARK: - Document View - - /// Get the document view. - /// - Returns: The document view. - public func documentView() -> NSView { - documentView! // swiftlint:disable:this force_unwrapping - } - // MARK: - Scroll + /// Whether scrolling is enabled, like `UIScrollView`'s `isScrollEnabled`. + /// + /// While scrolling is disabled, the scroll view passes scroll wheel events to its next responder. + public var isScrollEnabled: Bool = true + private var scrollSession: ScrollSession? override open func scrollWheel(with event: NSEvent) { + // https://apptyrant.com/2015/05/18/how-to-disable-nsscrollview-scrolling/ + guard isScrollEnabled else { + // send the event to outside of the scroll view. + // https://github.com/onmyway133/blog/issues/733 + nextResponder?.scrollWheel(with: event) + return + } + let scrollSession: ScrollSession if let currentScrollSession = self.scrollSession { if ScrollSession.isNewSession(with: event) { @@ -222,7 +265,7 @@ open class ScrollView: NSScrollView { if alwaysBounceHorizontal { horizontalScrollElasticity = .allowed } else { - if documentView().frame.width.extends(beyond: super.contentSize.width) { + if contentContainerView.frame.width.extends(beyond: super.contentSize.width) { horizontalScrollElasticity = .allowed } else { horizontalScrollElasticity = .none @@ -232,7 +275,7 @@ open class ScrollView: NSScrollView { if alwaysBounceVertical { verticalScrollElasticity = .allowed } else { - if documentView().frame.height.extends(beyond: super.contentSize.height) { + if contentContainerView.frame.height.extends(beyond: super.contentSize.height) { verticalScrollElasticity = .allowed } else { verticalScrollElasticity = .none @@ -342,8 +385,24 @@ private extension ScrollView { #endif +// MARK: - UIKit + #if canImport(UIKit) import UIKit public typealias ScrollView = UIScrollView #endif + +extension ScrollView { + + /// The view that holds the scroll view's content: the document view on AppKit, and the scroll view itself on UIKit. + var contentContainerView: View { + #if canImport(AppKit) + return documentView! // swiftlint:disable:this force_unwrapping + #endif + + #if canImport(UIKit) + return self + #endif + } +} diff --git a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollViewType.swift b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollViewType.swift deleted file mode 100644 index ebd407af..00000000 --- a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollViewType.swift +++ /dev/null @@ -1,251 +0,0 @@ -// -// ScrollViewType.swift -// ComposéUI -// -// Created by Honghao Zhang on 10/27/24. -// Copyright © 2024 Honghao Zhang. -// -// MIT License -// -// Copyright (c) 2024 Honghao Zhang (github.com/honghaoz) -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -// IN THE SOFTWARE. -// - -#if canImport(AppKit) -import AppKit -#endif - -#if canImport(UIKit) -import UIKit -#endif - -/// A type that represents a cross-platform scroll view. -public protocol ScrollViewType: ScrollView { - - /// The bounds of the scroll view. - func bounds() -> CGRect - - /// Set the bounds of the scroll view. - func setBounds(_ bounds: CGRect) - - /// The content size of the scroll view. - func contentSize() -> CGSize - - /// Set the content size of the scroll view. - func setContentSize(_ size: CGSize) - - /// The content insets of the scroll view. - func contentInsets() -> EdgeInsets - - /// Set the content insets of the scroll view. - func setContentInsets(_ insets: EdgeInsets) - - /// The view you should add your subviews to. - func contentView() -> View - - /// The content offset of the scroll view. - func contentOffset() -> CGPoint - - // TODO: clarify setContentOffset with animation or not - // we need to clean API for setting content offset with/without animation - // it seems like UIKit's `contentOffset = " will adjust the offset without affecting live animations - // while `self.setContentOffset(..., animated:)` can set offset and stop the live animations - - /// Set the content offset of the scroll view. - func setContentOffset(_ offset: CGPoint) - - /// The minimum horizontal offset that the scroll view can scroll to, without elastic effect. - var minOffsetX: CGFloat { get } - - /// The maximum horizontal offset that the scroll view can scroll to, without elastic effect. - var maxOffsetX: CGFloat { get } - - /// The minimum vertical offset that the scroll view can scroll to, without elastic effect. - var minOffsetY: CGFloat { get } - - /// The maximum vertical offset that the scroll view can scroll to, without elastic effect. - var maxOffsetY: CGFloat { get } - - /// Whether the scroll view can scroll to the left. - var canScrollToLeft: Bool { get } - - /// Whether the scroll view can scroll to the right. - var canScrollToRight: Bool { get } - - /// Whether the scroll view can scroll to the top. - var canScrollToTop: Bool { get } - - /// Whether the scroll view can scroll to the bottom. - var canScrollToBottom: Bool { get } - - /// A boolean value that determines whether bouncing always occurs when vertical scrolling reaches the end of the content. - var alwaysBounceVertical: Bool { get set } - - /// A boolean value that determines whether bouncing always occurs when horizontal scrolling reaches the end of the content view. - var alwaysBounceHorizontal: Bool { get set } - - /// A boolean value that determines whether the scroll view clips to its bounds. - var clipsToBounds: Bool { get set } -} - -extension ScrollView: ScrollViewType {} - -public extension ScrollViewType { - - func bounds() -> CGRect { - #if canImport(AppKit) - /// Can also use `documentVisibleRect` - return contentView.bounds - #endif - - #if canImport(UIKit) - return self.bounds - #endif - } - - func setBounds(_ bounds: CGRect) { - #if canImport(AppKit) - self.frame.size = bounds.size - self.contentView()?.scroll(CGPoint(x: 0, y: bounds.origin.y)) - #endif - - #if canImport(UIKit) - self.bounds = bounds - #endif - } - - func contentSize() -> CGSize { - #if canImport(AppKit) - return contentView().bounds.size - #endif - - #if canImport(UIKit) - return self.contentSize - #endif - } - - func setContentSize(_ size: CGSize) { - #if canImport(AppKit) - guard let documentView = documentView else { - ComposeUI.assertFailure("NSScrollView has no document view. Please set `documentView`.") - return - } - ComposeUI.assert(documentView.frame.origin == .zero) - documentView.frame = CGRect(origin: .zero, size: size) - #endif - - #if canImport(UIKit) - self.contentSize = size - #endif - } - - func contentInsets() -> EdgeInsets { - #if canImport(AppKit) - return contentInsets - #endif - - #if canImport(UIKit) - return adjustedContentInset - #endif - } - - func setContentInsets(_ insets: EdgeInsets) { - #if canImport(AppKit) - self.contentInsets = insets - #endif - - #if canImport(UIKit) - self.contentInset = insets - #endif - } - - func contentView() -> View { - #if canImport(AppKit) - guard let documentView = documentView else { - ComposeUI.assertFailure("NSScrollView has no document view. Please set `documentView`.") - return self - } - return documentView - #endif - - #if canImport(UIKit) - return self - #endif - } - - func contentOffset() -> CGPoint { - #if canImport(AppKit) - return bounds().origin - #endif - - #if canImport(UIKit) - return self.contentOffset - #endif - } - - func setContentOffset(_ offset: CGPoint) { - #if canImport(AppKit) - contentView()?.scroll(offset) - #endif - - #if canImport(UIKit) - self.contentOffset = offset - #endif - } - - var minOffsetX: CGFloat { - -contentInsets().left - } - - var maxOffsetX: CGFloat { - contentSize().width - bounds().width + contentInsets().right - } - - var minOffsetY: CGFloat { - -contentInsets().top - } - - var maxOffsetY: CGFloat { - contentSize().height - bounds().height + contentInsets().bottom - } - - var canScrollToLeft: Bool { - contentOffset().x > minOffsetX - } - - var canScrollToRight: Bool { - contentOffset().x < maxOffsetX - } - - var canScrollToTop: Bool { - contentOffset().y > minOffsetY - } - - var canScrollToBottom: Bool { - contentOffset().y < maxOffsetY - } - - #if canImport(UIKit) - /// Stops any ongoing scroll deceleration, freezing the scroll view at its current position. - func stopDecelerating() { - setContentOffset(contentOffset(), animated: false) - } - #endif -} diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ButtonNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ButtonNodeTests.swift index 29ba399f..64b7ce76 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ButtonNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ButtonNodeTests.swift @@ -234,7 +234,7 @@ class ButtonNodeTests: XCTestCase { ) button.refresh(animated: false) - let layer = try (button.contentView().layer().sublayers?.first).unwrap() + let layer = try (button.contentContainerView.layer().sublayers?.first).unwrap() var node = ButtonNode( content: { _ in ColorNode(.blue) @@ -267,7 +267,7 @@ class ButtonNodeTests: XCTestCase { // then: the new content and handler replace the prior configuration expect(layer.backgroundColor).toEventually(beEqual(to: Color.blue.cgColor)) expect(action) == "new double tap" - expect(button.contentView().layer().sublayers?.first) === layer + expect(button.contentContainerView.layer().sublayers?.first) === layer } func test_boundsChange_preservesPressedContentAndNormalMeasurement() throws { @@ -413,7 +413,7 @@ class ButtonNodeTests: XCTestCase { let button = try renderedButton.unwrap() button.setNeedsLayout() button.layoutIfNeeded() - let layer = try (button.contentView().layer().sublayers?.first).unwrap() + let layer = try (button.contentContainerView.layer().sublayers?.first).unwrap() // when: new data is supplied without a refresh and the parent resizes generation = 2 diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ColorNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ColorNodeTests.swift index 4f55ab88..ed33b60b 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ColorNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ColorNodeTests.swift @@ -331,7 +331,7 @@ class ColorNodeTests: XCTestCase { var renderedLayer: CALayer? var updateType: RenderableUpdateType? let contentView = ComposeView { contentView in - let color: Color = contentView.bounds().width < 150 ? .red : .blue + let color: Color = contentView.visibleSize.width < 150 ? .red : .blue ColorNode(color) .fixedId("color") .frame(width: 40, height: 40) @@ -475,7 +475,7 @@ class ColorNodeTests: XCTestCase { // when: scrolling to reveal the second row without refreshing changed data firstColor = .green secondColor = .yellow - contentView.setContentOffset(CGPoint(x: 0, y: 50)) + contentView.contentOffset = CGPoint(x: 0, y: 50) contentView.setNeedsLayout() contentView.layoutIfNeeded() diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+AnimationTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+AnimationTests.swift index 2c5a6386..03f1dcde 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+AnimationTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+AnimationTests.swift @@ -146,7 +146,7 @@ class ComposeViewNode_AnimationTests: XCTestCase { expect(childView) == nil // when: scrolling inserts the nested view - contentView.setContentOffset(CGPoint(x: 0, y: 125)) + contentView.contentOffset = CGPoint(x: 0, y: 125) contentView.layoutIfNeeded() // then: the nested view and its content both run their insert transitions, while the scroll pass allows no @@ -252,7 +252,7 @@ class ComposeViewNode_AnimationTests: XCTestCase { } // when: scrolling the outer view offscreen starts its removal transition - parent.setContentOffset(CGPoint(x: 0, y: 200)) + parent.contentOffset = CGPoint(x: 0, y: 200) parent.layoutIfNeeded() // then: the removed view and its descendants remain mounted during removal @@ -263,7 +263,7 @@ class ComposeViewNode_AnimationTests: XCTestCase { expect(removal.toValue as? Float) == 0 expect(removal.duration) == 10 expect(removal.isAdditive) == true - expect(reusedLayer.superlayer) === originalViews[0].contentView().layer() + expect(reusedLayer.superlayer) === originalViews[0].contentContainerView.layer() // when: the parent resizes while the nested subtree is still offscreen parent.frame.size.width = 160 @@ -278,7 +278,7 @@ class ComposeViewNode_AnimationTests: XCTestCase { renderTypes.removeAll() // when: scrolling back revives the outer view before removal completes - parent.setContentOffset(.zero) + parent.contentOffset = .zero parent.layoutIfNeeded() // then: every nested view is reused and refreshes with the parent's separate decisions @@ -387,7 +387,7 @@ class ComposeViewNode_AnimationTests: XCTestCase { } // when: scrolling the nested view offscreen starts its removal transition - parent.setContentOffset(CGPoint(x: 0, y: 200)) + parent.contentOffset = CGPoint(x: 0, y: 200) parent.layoutIfNeeded() // then: the nested view stays mounted during the removal @@ -405,7 +405,7 @@ class ComposeViewNode_AnimationTests: XCTestCase { childRenderType = nil // when: scrolling back revives the nested view before its removal completes - parent.setContentOffset(.zero) + parent.contentOffset = .zero parent.layoutIfNeeded() // then: the host's scroll decision caps the nested view's own behavior, so the reused row snaps to its new diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+ParentResizeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+ParentResizeTests.swift index 52c8b93f..6b65bcc0 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+ParentResizeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+ParentResizeTests.swift @@ -170,7 +170,7 @@ class ComposeViewNode_ParentResizeTests: XCTestCase { // when: the parent scrolls with the nested views still visible nestedContexts.removeAll() - parent.setContentOffset(CGPoint(x: 0, y: 50)) + parent.contentOffset = CGPoint(x: 0, y: 50) parent.layoutIfNeeded() // then: the parent's render pass updates the outermost nested view, whose size is unchanged, so neither its @@ -302,9 +302,9 @@ class ComposeViewNode_ParentResizeTests: XCTestCase { parent.refresh(animated: false) let child = try unwrap(childView) let row = try unwrap(layer) - child.setContentOffset(CGPoint(x: 0, y: 290)) + child.contentOffset = CGPoint(x: 0, y: 290) child.layoutIfNeeded() - expect(child.contentOffset()) == CGPoint(x: 0, y: 290) + expect(child.contentOffset) == CGPoint(x: 0, y: 290) expect(row.frame) == CGRect(x: 0, y: 0, width: 100, height: 400) row.removeAllAnimations() childRenderTypes.removeAll() @@ -317,7 +317,7 @@ class ComposeViewNode_ParentResizeTests: XCTestCase { // then: the one layout rendering the new bounds is capped by the parent's decision, so the row snaps expect(child.frame) == CGRect(x: 0, y: 0, width: 140, height: 140) - expect(child.contentOffset()) == CGPoint(x: 0, y: 260) + expect(child.contentOffset) == CGPoint(x: 0, y: 260) expect(row.frame) == CGRect(x: 0, y: 0, width: 140, height: 400) expect(row.animationKeys()) == nil expect(childRenderTypes) == [.boundsChange( @@ -880,9 +880,9 @@ class ComposeViewNode_ParentResizeTests: XCTestCase { parent.refresh(animated: false) let child = try unwrap(childView) let row = try unwrap(layer) - child.setContentOffset(CGPoint(x: 0, y: 290)) + child.contentOffset = CGPoint(x: 0, y: 290) child.layoutIfNeeded() - expect(child.contentOffset()) == CGPoint(x: 0, y: 290) + expect(child.contentOffset) == CGPoint(x: 0, y: 290) expect(row.frame) == CGRect(x: 0, y: 0, width: 100, height: 400) row.removeAllAnimations() childRenderTypes.removeAll() @@ -898,12 +898,12 @@ class ComposeViewNode_ParentResizeTests: XCTestCase { expect(child.frame) == CGRect(x: 0, y: 0, width: 140, height: 140) expect(row.frame) == CGRect(x: 0, y: 0, width: 140, height: 400) expect(row.animationKeys()) == nil - expect(child.contentOffset()) == CGPoint(x: 0, y: child.maxOffsetY) - expect(child.contentOffset().y) < 290 + expect(child.contentOffset) == CGPoint(x: 0, y: child.maxOffsetY) + expect(child.contentOffset.y) < 290 expect(childRenderTypes) == [ .boundsChange( previousBounds: CGRect(x: 0, y: 290, width: 100, height: 100), - bounds: CGRect(origin: child.contentOffset(), size: CGSize(width: 140, height: 140)) + bounds: CGRect(origin: child.contentOffset, size: CGSize(width: 140, height: 140)) ), ] expect(childRenderTypes.count) == layerContexts.count diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNodeTests.swift index f8144915..781a84b7 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNodeTests.swift @@ -295,7 +295,7 @@ class ComposeViewNodeTests: XCTestCase { do { let composeView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) let renderable = Renderable.view(composeView) - let layer = composeView.contentView().layer() + let layer = composeView.contentContainerView.layer() // when: the view is inserted item.update(renderable, RenderableUpdateContext(updateType: .insert, oldFrame: .zero, newFrame: composeView.frame, previousRenderBounds: .zero, renderBounds: .zero, animationTiming: nil, contentView: nil, contentEvaluation: nil, animationDecision: ComposeView.AnimationDecision.disabled)) @@ -339,7 +339,7 @@ class ComposeViewNodeTests: XCTestCase { } nestedView.frame = frame nestedView.refresh(animated: false) - let layer = try (nestedView.contentView().layer().sublayers?.first).unwrap() + let layer = try (nestedView.contentContainerView.layer().sublayers?.first).unwrap() let renderable = Renderable.view(nestedView) for renderBounds in [frame.offsetBy(dx: 0, dy: 20), CGRect(x: 0, y: 0, width: 100, height: 60)] { @@ -357,7 +357,7 @@ class ComposeViewNodeTests: XCTestCase { // then: the new color is applied immediately without replacing the layer expect(layer.backgroundColor) == Color.blue.cgColor - expect(nestedView.contentView().layer().sublayers?.first) === layer + expect(nestedView.contentContainerView.layer().sublayers?.first) === layer } func test_refresh_updatesMountedContentWithUnchangedSize() throws { @@ -513,7 +513,7 @@ class ComposeViewNodeTests: XCTestCase { // then: the pass completes with its own content, one nested view, and no assertion expect(assertionMessages) == [] expect(nestedViews.count) == 1 - expect(parent.contentView().subviews.filter { $0 is ComposeView }.count) == 1 + expect(parent.contentContainerView.subviews.filter { $0 is ComposeView }.count) == 1 expect(colorLayer?.backgroundColor) == Color.red.cgColor expect(parentRefreshesFromChild) == 1 @@ -525,7 +525,7 @@ class ComposeViewNodeTests: XCTestCase { // then: the nested view shows the new configuration and is still the only nested view expect(assertionMessages) == [] expect(nestedViews.count) == 1 - expect(parent.contentView().subviews.filter { $0 is ComposeView }.count) == 1 + expect(parent.contentContainerView.subviews.filter { $0 is ComposeView }.count) == 1 expect(colorLayer?.backgroundColor) == Color.blue.cgColor } @@ -618,11 +618,11 @@ class ComposeViewNodeTests: XCTestCase { contentView.refresh(animated: false) let child = try unwrap(nestedView) child.scrollBehavior = .always - child.setContentInsets(EdgeInsets(top: 0, left: 0, bottom: 50, right: 0)) + child.contentInset = EdgeInsets(top: 0, left: 0, bottom: 50, right: 0) child.setNeedsLayout() child.layoutIfNeeded() let layer = try unwrap(colorLayer) - expect(child.bounds().size) == CGSize(width: 100, height: 300) + expect(child.visibleSize) == CGSize(width: 100, height: 300) expect(layer.frame) == CGRect(x: 0, y: 0, width: 100, height: 300) // when: the parent resizes, measuring its own copy of the content at the new size, while the nested view keeps @@ -635,17 +635,17 @@ class ComposeViewNodeTests: XCTestCase { // then: the nested view keeps its original bounds expect(nestedView) === child - expect(child.bounds().size) == CGSize(width: 100, height: 300) + expect(child.visibleSize) == CGSize(width: 100, height: 300) // when: scrolling causes the nested view to render from its cached layout colorUpdateType = nil - child.setContentOffset(CGPoint(x: 0, y: 10)) + child.contentOffset = CGPoint(x: 0, y: 10) child.setNeedsLayout() child.layoutIfNeeded() // then: the reused content keeps the nested view's frame, not the parent's measurement size expect(colorUpdateType) == .boundsChange - expect(child.contentOffset().y) == 10 + expect(child.contentOffset.y) == 10 expect(colorLayer) === layer expect(layer.frame) == CGRect(x: 0, y: 0, width: 100, height: 300) expect(layer.backgroundColor) == Color.red.cgColor @@ -691,7 +691,7 @@ class ComposeViewNodeTests: XCTestCase { contentView.frame.size.width = 160 contentView.setNeedsLayout() contentView.layoutIfNeeded() - contentView.setContentOffset(CGPoint(x: 0, y: 125)) + contentView.contentOffset = CGPoint(x: 0, y: 125) contentView.layoutIfNeeded() // then: delayed insertion renders the reused content within the scroll pass @@ -701,7 +701,7 @@ class ComposeViewNodeTests: XCTestCase { expect(colorLayer?.backgroundColor) == Color.red.cgColor // when: scrolling the nested view offscreen removes it - contentView.setContentOffset(.zero) + contentView.contentOffset = .zero contentView.layoutIfNeeded() // then: the first nested view is detached @@ -711,7 +711,7 @@ class ComposeViewNodeTests: XCTestCase { nestedView = nil textView = nil colorLayer = nil - contentView.setContentOffset(CGPoint(x: 0, y: 125)) + contentView.contentOffset = CGPoint(x: 0, y: 125) contentView.layoutIfNeeded() // then: reinsertion initializes the nested content without reevaluating application data diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/DropShadowNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/DropShadowNodeTests.swift index b94ec088..5bfc10ed 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/DropShadowNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/DropShadowNodeTests.swift @@ -752,10 +752,10 @@ class DropShadowNodeTests: XCTestCase { // then: the first shadow layer is configured with the shadow path #if canImport(AppKit) - let shadowLayer1 = try unwrap(view.contentView().layer?.sublayers?.first) + let shadowLayer1 = try unwrap(view.contentContainerView.layer?.sublayers?.first) #endif #if canImport(UIKit) - let shadowLayer1 = try unwrap(view.contentView().layer.sublayers?.first) + let shadowLayer1 = try unwrap(view.contentContainerView.layer.sublayers?.first) #endif expect(shadowLayer1.shadowColor) == Color.black.cgColor @@ -766,10 +766,10 @@ class DropShadowNodeTests: XCTestCase { // then: the second shadow layer is configured with a cutout mask #if canImport(AppKit) - let shadowLayer2 = try unwrap(view.contentView().layer?.sublayers?[1]) + let shadowLayer2 = try unwrap(view.contentContainerView.layer?.sublayers?[1]) #endif #if canImport(UIKit) - let shadowLayer2 = try unwrap(view.contentView().layer.sublayers?[1]) + let shadowLayer2 = try unwrap(view.contentContainerView.layer.sublayers?[1]) #endif expect(shadowLayer2.shadowColor) == Color.black.cgColor diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/GestureRecognizerNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/GestureRecognizerNodeTests.swift index 1b1690af..6a576374 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/GestureRecognizerNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/GestureRecognizerNodeTests.swift @@ -291,7 +291,7 @@ class GestureRecognizerNodeTests: XCTestCase { var renderedGestureView: View? var updateType: RenderableUpdateType? let contentView = ComposeView { contentView in - let isNarrow = contentView.bounds().width < 150 + let isNarrow = contentView.visibleSize.width < 150 let color: Color = isNarrow ? .red : .blue VStack(alignment: .left) { LayerNode() @@ -373,7 +373,7 @@ class GestureRecognizerNodeTests: XCTestCase { // when: scrolling with changed data but without an explicit refresh additionalTapCount = 1 - contentView.setContentOffset(CGPoint(x: 0, y: 50)) + contentView.contentOffset = CGPoint(x: 0, y: 50) contentView.setNeedsLayout() contentView.layoutIfNeeded() @@ -562,7 +562,7 @@ class GestureRecognizerNodeTests: XCTestCase { expect(gestureView.layer().backgroundColor) == Color.green.cgColor // when: scrolling during the active pan - contentView.setContentOffset(CGPoint(x: 0, y: 30)) + contentView.contentOffset = CGPoint(x: 0, y: 30) contentView.setNeedsLayout() contentView.layoutIfNeeded() diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/InnerShadowNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/InnerShadowNodeTests.swift index 1d4ce625..c296014c 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/InnerShadowNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/InnerShadowNodeTests.swift @@ -745,10 +745,10 @@ class InnerShadowNodeTests: XCTestCase { // then: the first inner shadow layer is configured with a full clip mask #if canImport(AppKit) - let shadowLayer1 = try unwrap(view.contentView().layer?.sublayers?[0]) + let shadowLayer1 = try unwrap(view.contentContainerView.layer?.sublayers?[0]) #endif #if canImport(UIKit) - let shadowLayer1 = try unwrap(view.contentView().layer.sublayers?[0]) + let shadowLayer1 = try unwrap(view.contentContainerView.layer.sublayers?[0]) #endif expect(shadowLayer1.shadowColor) == Color.black.cgColor @@ -763,10 +763,10 @@ class InnerShadowNodeTests: XCTestCase { // then: the second inner shadow layer is configured with an inset clip mask #if canImport(AppKit) - let shadowLayer2 = try unwrap(view.contentView().layer?.sublayers?[1]) + let shadowLayer2 = try unwrap(view.contentContainerView.layer?.sublayers?[1]) #endif #if canImport(UIKit) - let shadowLayer2 = try unwrap(view.contentView().layer.sublayers?[1]) + let shadowLayer2 = try unwrap(view.contentContainerView.layer.sublayers?[1]) #endif expect(shadowLayer2.shadowColor) == Color.black.cgColor diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/LabelNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/LabelNodeTests.swift index 265285e6..973ec59a 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/LabelNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/LabelNodeTests.swift @@ -851,7 +851,7 @@ class LabelNodeTests: XCTestCase { } private func firstBaseTextView(in view: ComposeView) -> BaseTextView? { - view.contentView().subviews.compactMap { $0 as? BaseTextView }.first + view.contentContainerView.subviews.compactMap { $0 as? BaseTextView }.first } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/LayerNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/LayerNodeTests.swift index 28044a0d..ac93a0bc 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/LayerNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/LayerNodeTests.swift @@ -404,7 +404,7 @@ class LayerNodeTests: XCTestCase { let contentView = ComposeView { LayerNode(update: { layer, context in layer.cornerRadius = context.newFrame.width / 4 - layer.backgroundColor = context.contentView.contentOffset().y > 0 ? Color.blue.cgColor : Color.red.cgColor + layer.backgroundColor = context.contentView.contentOffset.y > 0 ? Color.blue.cgColor : Color.red.cgColor renderedLayer = layer updateType = context.updateType }) @@ -435,7 +435,7 @@ class LayerNodeTests: XCTestCase { expect(updateType) == .boundsChange // when: the container scrolls - contentView.setContentOffset(CGPoint(x: 0, y: 20)) + contentView.contentOffset = CGPoint(x: 0, y: 20) contentView.layoutIfNeeded() // then: custom updates can also apply scroll-dependent configuration without changing the content-space frame @@ -461,12 +461,12 @@ class LayerNodeTests: XCTestCase { // then: the layer is rendered centered at its fixed size #if canImport(AppKit) - expect(view.contentView().layer?.sublayers?.count) == 1 - expect(view.contentView().layer?.sublayers?[0].frame) == CGRect(x: 25, y: 25, width: 50, height: 50) + expect(view.contentContainerView.layer?.sublayers?.count) == 1 + expect(view.contentContainerView.layer?.sublayers?[0].frame) == CGRect(x: 25, y: 25, width: 50, height: 50) #endif #if canImport(UIKit) - expect(view.contentView().layer.sublayers?.count) == 1 - expect(view.contentView().layer.sublayers?[0].frame) == CGRect(x: 25, y: 25, width: 50, height: 50) + expect(view.contentContainerView.layer.sublayers?.count) == 1 + expect(view.contentContainerView.layer.sublayers?[0].frame) == CGRect(x: 25, y: 25, width: 50, height: 50) #endif } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/SwiftUIViewNode+ContentEvaluationTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/SwiftUIViewNode+ContentEvaluationTests.swift index a7d49b71..79c853ac 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/SwiftUIViewNode+ContentEvaluationTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/SwiftUIViewNode+ContentEvaluationTests.swift @@ -118,7 +118,7 @@ class SwiftUIViewNode_ContentEvaluationTests: XCTestCase { // when: the node first becomes visible after external data changes suppliedWidth = 90 - contentView.setContentOffset(CGPoint(x: 0, y: 150)) + contentView.contentOffset = CGPoint(x: 0, y: 150) contentView.layoutIfNeeded() // then: insertion evaluates the current provider exactly once @@ -131,7 +131,7 @@ class SwiftUIViewNode_ContentEvaluationTests: XCTestCase { contentView.frame.size.width = 160 contentView.setNeedsLayout() contentView.layoutIfNeeded() - contentView.setContentOffset(CGPoint(x: 0, y: 160)) + contentView.contentOffset = CGPoint(x: 0, y: 160) contentView.layoutIfNeeded() // then: resizing and scrolling keep the resolved value @@ -140,11 +140,11 @@ class SwiftUIViewNode_ContentEvaluationTests: XCTestCase { expect(providerCalls) == 1 // when: the same node is removed and inserted again before a refresh - contentView.setContentOffset(.zero) + contentView.contentOffset = .zero contentView.layoutIfNeeded() expect(view.superview) == nil renderedView = nil - contentView.setContentOffset(CGPoint(x: 0, y: 150)) + contentView.contentOffset = CGPoint(x: 0, y: 150) contentView.layoutIfNeeded() // then: the new host receives the existing resolved content @@ -235,7 +235,7 @@ class SwiftUIViewNode_ContentEvaluationTests: XCTestCase { // when: scrolling reveals the node after its external data changes again suppliedSize = CGSize(width: 160, height: 90) - contentView.setContentOffset(CGPoint(x: 0, y: 100)) + contentView.contentOffset = CGPoint(x: 0, y: 100) contentView.layoutIfNeeded() // then: late insertion mounts the same content that determined its intrinsic size @@ -328,7 +328,7 @@ class SwiftUIViewNode_ContentEvaluationTests: XCTestCase { // when: measuring fresh content after external data changes width = 140 let measuredSize = contentView.sizeThatFits(CGSize(width: 240, height: 100)) - contentView.setContentOffset(CGPoint(x: 0, y: 20)) + contentView.contentOffset = CGPoint(x: 0, y: 20) contentView.layoutIfNeeded() contentView.frame.size.width = 240 contentView.setNeedsLayout() @@ -402,7 +402,7 @@ class SwiftUIViewNode_ContentEvaluationTests: XCTestCase { first.frame.size.width = 240 first.setNeedsLayout() first.layoutIfNeeded() - first.setContentOffset(CGPoint(x: 0, y: 20)) + first.contentOffset = CGPoint(x: 0, y: 20) first.layoutIfNeeded() // then: another host's evaluation does not invalidate the first host's value @@ -526,7 +526,7 @@ class SwiftUIViewNode_ContentEvaluationTests: XCTestCase { let view = try innerHost.unwrap() // then: the nested host renders the value used to measure its parent's frame - expect(nestedView?.bounds().size) == CGSize(width: 80, height: 50) + expect(nestedView?.visibleSize) == CGSize(width: 80, height: 50) expect(view.content.sizeThatFits(view.bounds.size)) == CGSize(width: 80, height: 50) expect(providerCalls) == 1 @@ -573,7 +573,7 @@ class SwiftUIViewNode_ContentEvaluationTests: XCTestCase { // then: the child uses the value measured by the pass rather than evaluating against the new host state let host = try unwrap(innerHost) - expect(child.bounds().size) == CGSize(width: 80, height: 50) + expect(child.visibleSize) == CGSize(width: 80, height: 50) expect(host.content.sizeThatFits(host.bounds.size)) == CGSize(width: 80, height: 50) expect(providerCalls) == 1 } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/TextNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/TextNodeTests.swift index cfb70938..c4b4f5df 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/TextNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/TextNodeTests.swift @@ -346,7 +346,7 @@ class TextNodeTests: XCTestCase { #endif // when: the reused text view scrolls - contentView.setContentOffset(CGPoint(x: 0, y: 20)) + contentView.contentOffset = CGPoint(x: 0, y: 20) contentView.layoutIfNeeded() // then: scrolling preserves the selection and edited text diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ViewNodeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ViewNodeTests.swift index 679e84ad..e7ac628e 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ViewNodeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ViewNodeTests.swift @@ -460,8 +460,8 @@ class ViewNodeTests: XCTestCase { view.refresh(animated: false) // then: the view is added and centered - expect(view.contentView().subviews.count) == 1 - expect(view.contentView().subviews[0].frame) == CGRect(x: 25, y: 25, width: 50, height: 50) + expect(view.contentContainerView.subviews.count) == 1 + expect(view.contentContainerView.subviews[0].frame) == CGRect(x: 25, y: 25, width: 50, height: 50) } func test_renderableItems() throws { @@ -570,7 +570,7 @@ class ViewNodeTests: XCTestCase { let contentView = ComposeView { ViewNode(update: { view, context in view.layer().cornerRadius = context.newFrame.width / 4 - view.layer().backgroundColor = context.contentView.contentOffset().y > 0 ? Color.blue.cgColor : Color.red.cgColor + view.layer().backgroundColor = context.contentView.contentOffset.y > 0 ? Color.blue.cgColor : Color.red.cgColor renderedView = view updateType = context.updateType }) @@ -599,7 +599,7 @@ class ViewNodeTests: XCTestCase { expect(updateType) == .boundsChange // when: the container scrolls - contentView.setContentOffset(CGPoint(x: 0, y: 20)) + contentView.contentOffset = CGPoint(x: 0, y: 20) contentView.layoutIfNeeded() // then: custom updates can also apply scroll-dependent configuration diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationBehaviorTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationBehaviorTests.swift index c822d8d4..8b92ee74 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationBehaviorTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationBehaviorTests.swift @@ -68,7 +68,7 @@ class ComposeView_AnimationBehaviorTests: XCTestCase { expect(layer2Context) == nil // when: scrolling - view.setContentOffset(CGPoint(x: 0, y: 4)) + view.contentOffset = CGPoint(x: 0, y: 4) view.layoutIfNeeded() // then: reused renderables follow scrolling immediately, and insertion has no frame animation @@ -393,7 +393,7 @@ class ComposeView_AnimationBehaviorTests: XCTestCase { expect(layer2Context) == nil // when: scrolling - view.setContentOffset(CGPoint(x: 0, y: 4)) + view.contentOffset = CGPoint(x: 0, y: 4) view.layoutIfNeeded() // then: no animation for scrolling or insertion @@ -465,7 +465,7 @@ class ComposeView_AnimationBehaviorTests: XCTestCase { calledPreviousBounds = nil // when: scrolling to the offset the behavior rejects - view.setContentOffset(CGPoint(x: 0, y: 4)) + view.contentOffset = CGPoint(x: 0, y: 4) view.layoutIfNeeded() // then: no animation for scrolling or insertion, the behavior receives the previous bounds @@ -478,7 +478,7 @@ class ComposeView_AnimationBehaviorTests: XCTestCase { calledPreviousBounds = nil // when: scrolling to an offset the behavior accepts - view.setContentOffset(CGPoint(x: 0, y: 5)) + view.contentOffset = CGPoint(x: 0, y: 5) view.layoutIfNeeded() // then: has animation for scrolling, the behavior receives the previous bounds @@ -491,7 +491,7 @@ class ComposeView_AnimationBehaviorTests: XCTestCase { calledPreviousBounds = nil // when: scrolling again - view.setContentOffset(CGPoint(x: 0, y: 6)) + view.contentOffset = CGPoint(x: 0, y: 6) view.layoutIfNeeded() // then: has animation for scrolling, the behavior receives the previous bounds @@ -546,7 +546,7 @@ class ComposeView_AnimationBehaviorTests: XCTestCase { } // when: one scroll removes a row, reuses a row, and reveals a row - view.setContentOffset(CGPoint(x: 0, y: 100)) + view.contentOffset = CGPoint(x: 0, y: 100) view.layoutIfNeeded() // then: the behavior is asked once and its answer applies to the removal, the reused row's update, and the insertion @@ -563,7 +563,7 @@ class ComposeView_AnimationBehaviorTests: XCTestCase { expect(thirdRow.animation(forKey: "opacity")) != nil // when: the next scroll removes, reuses, and reveals again - view.setContentOffset(CGPoint(x: 0, y: 200)) + view.contentOffset = CGPoint(x: 0, y: 200) view.layoutIfNeeded() // then: the flipped answer applies to the whole pass, so nothing animates @@ -688,7 +688,7 @@ class ComposeView_AnimationBehaviorTests: XCTestCase { #if canImport(AppKit) view.scrollIndicatorBehavior = .auto - // use legacy scrollers so the scroller thickness affects bounds(). + // use legacy scrollers so the scroller thickness affects the visible size. view.scrollerStyle = .legacy view.hasHorizontalScroller = true view.hasVerticalScroller = true @@ -696,36 +696,38 @@ class ComposeView_AnimationBehaviorTests: XCTestCase { view.layoutIfNeeded() + expect(view.contentOffset) == .zero #if canImport(AppKit) // verify the scrollers does affect the bounds if #available(macOS 26.0, *) { - expect(view.bounds()) == CGRect(x: 0, y: 0, width: 103, height: 63) + expect(view.visibleSize) == CGSize(width: 103, height: 63) } else { - expect(view.bounds()) == CGRect(x: 0, y: 0, width: 105, height: 65) + expect(view.visibleSize) == CGSize(width: 105, height: 65) } #endif #if canImport(UIKit) - expect(view.bounds()) == CGRect(x: 0, y: 0, width: 120, height: 80) + expect(view.visibleSize) == CGSize(width: 120, height: 80) #endif // with default animation behavior view.animationBehavior = .default // when: scroll the view - view.setContentOffset(CGPoint(x: 0, y: 10)) + view.contentOffset = CGPoint(x: 0, y: 10) view.layoutIfNeeded() // then: the bounds reflect the scroll while the reused renderable's update stays immediate + expect(view.contentOffset) == CGPoint(x: 0, y: 10) #if canImport(AppKit) // verify the scrollers does affect the bounds if #available(macOS 26.0, *) { - expect(view.bounds()) == CGRect(x: 0, y: 10, width: 103, height: 63) + expect(view.visibleSize) == CGSize(width: 103, height: 63) } else { - expect(view.bounds()) == CGRect(x: 0, y: 10, width: 105, height: 65) + expect(view.visibleSize) == CGSize(width: 105, height: 65) } #endif #if canImport(UIKit) - expect(view.bounds()) == CGRect(x: 0, y: 10, width: 120, height: 80) + expect(view.visibleSize) == CGSize(width: 120, height: 80) #endif // the reused renderable's update does not animate @@ -738,7 +740,7 @@ class ComposeView_AnimationBehaviorTests: XCTestCase { return false } - view.setContentOffset(CGPoint(x: 0, y: 20)) + view.contentOffset = CGPoint(x: 0, y: 20) view.layoutIfNeeded() // then: the render type should have correct previous bounds diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationDecisionTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationDecisionTests.swift index e1de7f58..a8777b5a 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationDecisionTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationDecisionTests.swift @@ -60,7 +60,7 @@ class ComposeView_AnimationDecisionTests: XCTestCase { let reused = try unwrap(layers[1]) // when: scrolling removes a row, reuses a row, and inserts another - view.setContentOffset(CGPoint(x: 0, y: 100)) + view.contentOffset = CGPoint(x: 0, y: 100) view.layoutIfNeeded() // then: the leaving and entering rows transition while the reused row updates immediately @@ -85,7 +85,7 @@ class ComposeView_AnimationDecisionTests: XCTestCase { // when: another scroll reuses the entering row during its insertion transition let insertionBeginTime = insertion.beginTime let insertionKeys = entering.animationKeys() - view.setContentOffset(CGPoint(x: 0, y: 110)) + view.contentOffset = CGPoint(x: 0, y: 110) view.layoutIfNeeded() // then: the existing transition continues without restarting or adding update animations @@ -269,7 +269,7 @@ class ComposeView_AnimationDecisionTests: XCTestCase { // when: repeated scroll positions change the renderable's frame for offset: CGFloat in [20, 40, 10] { - view.setContentOffset(CGPoint(x: 0, y: offset)) + view.contentOffset = CGPoint(x: 0, y: offset) view.layoutIfNeeded() // then: the frame follows the offset without creating additive frame animations @@ -515,7 +515,7 @@ class ComposeView_AnimationDecisionTests: XCTestCase { } // when: the same reused item scrolls - view.setContentOffset(CGPoint(x: 0, y: 20)) + view.contentOffset = CGPoint(x: 0, y: 20) view.layoutIfNeeded() // then: scroll also leaves the existing animations running unchanged diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+CachedLayoutTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+CachedLayoutTests.swift index 17565fed..63fe73f1 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+CachedLayoutTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+CachedLayoutTests.swift @@ -56,7 +56,7 @@ class ComposeView_CachedLayoutTests: XCTestCase { expect(state.renderCount) == 1 // initial render // when: the view scrolls - view.setContentOffset(CGPoint(x: 0, y: 100)) + view.contentOffset = CGPoint(x: 0, y: 100) view.setNeedsLayout() view.layoutIfNeeded() @@ -66,7 +66,7 @@ class ComposeView_CachedLayoutTests: XCTestCase { expect(state.renderCount) == 2 // scroll should trigger render // when: the view scrolls again - view.setContentOffset(CGPoint(x: 0, y: 200)) + view.contentOffset = CGPoint(x: 0, y: 200) view.setNeedsLayout() view.layoutIfNeeded() @@ -96,7 +96,7 @@ class ComposeView_CachedLayoutTests: XCTestCase { expect(state.renderCount) == 5 // size change should trigger render // when: the view scrolls after the size change - view.setContentOffset(CGPoint(x: 0, y: 110)) + view.contentOffset = CGPoint(x: 0, y: 110) view.setNeedsLayout() view.layoutIfNeeded() diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ContentUpdateContextTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ContentUpdateContextTests.swift index 496e46dc..1392c044 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ContentUpdateContextTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ContentUpdateContextTests.swift @@ -111,7 +111,7 @@ class ComposeView_ContentUpdateContextTests: XCTestCase { expect(originalLayer.bounds.size) == CGSize(width: 150, height: 300) // when: the view scrolls - view.setContentOffset(CGPoint(x: 0, y: 20)) + view.contentOffset = CGPoint(x: 0, y: 20) view.layoutIfNeeded() // then: scrolling also reuses the pair and forwards its evaluation @@ -199,7 +199,7 @@ class ComposeView_ContentUpdateContextTests: XCTestCase { } // when: the view scrolls - view.setContentOffset(CGPoint(x: 0, y: 20)) + view.contentOffset = CGPoint(x: 0, y: 20) view.layoutIfNeeded() // then: scroll updates of reused renderables are immediate by default @@ -242,7 +242,7 @@ class ComposeView_ContentUpdateContextTests: XCTestCase { expect(itemContext?.animationTiming) == nil // when: the view scrolls under the dynamic behavior - view.setContentOffset(CGPoint(x: 0, y: 40)) + view.contentOffset = CGPoint(x: 0, y: 40) view.layoutIfNeeded() // then: the closure sees the scroll and its decision reaches the item diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+PreparedContentTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+PreparedContentTests.swift index 0fefe521..37be3b14 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+PreparedContentTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+PreparedContentTests.swift @@ -431,7 +431,7 @@ class ComposeView_PreparedContentTests: XCTestCase { // then: the prepared content remains unapplied expect(child.refreshCount) == 1 expect(hostedView) == nil - expect(child.contentView().layer().sublayers?.isEmpty ?? true) == true + expect(child.contentContainerView.layer().sublayers?.isEmpty ?? true) == true // when: a later request allows the base implementation to render after external data changes width = 140 @@ -446,7 +446,7 @@ class ComposeView_PreparedContentTests: XCTestCase { expect(child.lastAnimated) == false expect(renderedEvaluation) === evaluation expect(host.bounds.size) == CGSize(width: 80, height: 50) - expect(host.content.sizeThatFits(child.bounds().size)) == CGSize(width: 80, height: 50) + expect(host.content.sizeThatFits(child.visibleSize)) == CGSize(width: 80, height: 50) // when: the child refreshes after consuming the prepared evaluation child.refresh(animated: false) @@ -456,7 +456,7 @@ class ComposeView_PreparedContentTests: XCTestCase { expect(renderedEvaluation) !== evaluation expect(hostedView) === host expect(host.bounds.size) == CGSize(width: 140, height: 50) - expect(host.content.sizeThatFits(child.bounds().size)) == CGSize(width: 140, height: 50) + expect(host.content.sizeThatFits(child.visibleSize)) == CGSize(width: 140, height: 50) } func test_refreshOverride_appliesReplacementSuppliedAfterSuper() throws { @@ -487,7 +487,7 @@ class ComposeView_PreparedContentTests: XCTestCase { expect(colors) == [ComposeUI.Color.red.cgColor, ComposeUI.Color.blue.cgColor] expect(child.refreshCount) == 2 expect(child.lastAnimated) == false - expect(child.contentView().layer().sublayers?.first?.backgroundColor) == ComposeUI.Color.blue.cgColor + expect(child.contentContainerView.layer().sublayers?.first?.backgroundColor) == ComposeUI.Color.blue.cgColor } func test_refreshOverride_replacementSuppliedBeforeSuper_supersedesOriginalContent() throws { @@ -518,8 +518,8 @@ class ComposeView_PreparedContentTests: XCTestCase { expect(colors.isEmpty) == false expect(colors.contains(ComposeUI.Color.red.cgColor)) == false expect(child.refreshCount) == 2 - expect(child.contentView().layer().sublayers?.count) == 1 - expect(child.contentView().layer().sublayers?.first?.backgroundColor) == ComposeUI.Color.blue.cgColor + expect(child.contentContainerView.layer().sublayers?.count) == 1 + expect(child.contentContainerView.layer().sublayers?.first?.backgroundColor) == ComposeUI.Color.blue.cgColor } func test_refreshOverrideWithoutSuper_appliesReplacementSuppliedByOverride() throws { @@ -536,9 +536,9 @@ class ComposeView_PreparedContentTests: XCTestCase { child.setPreparedContent(ColorNode(.red), contentEvaluation: nil, animationDecision: ComposeView.AnimationDecision.disabled) // then: the replacement renders instead of the superseded content - expect(child.contentView().layer().sublayers?.first?.backgroundColor) == ComposeUI.Color.blue.cgColor + expect(child.contentContainerView.layer().sublayers?.first?.backgroundColor) == ComposeUI.Color.blue.cgColor expect(child.refreshCount) == 2 - expect(child.contentView().layer().sublayers?.count) == 1 + expect(child.contentContainerView.layer().sublayers?.count) == 1 } func test_refreshOverride_replacementKeepsItsOwnAnimationDecision() throws { @@ -603,7 +603,7 @@ class ComposeView_PreparedContentTests: XCTestCase { child.callsSuper = false child.setPreparedContent(ColorNode(.blue), contentEvaluation: preparedEvaluation, animationDecision: ComposeView.AnimationDecision.disabled) expect(child.refreshCount) == 1 - expect(child.contentView().layer().sublayers?.isEmpty ?? true) == true + expect(child.contentContainerView.layer().sublayers?.isEmpty ?? true) == true // when: the application replaces the builder before the prepared content is applied child.callsSuper = true @@ -620,7 +620,7 @@ class ComposeView_PreparedContentTests: XCTestCase { expect(child.refreshCount) == 2 expect(layer?.backgroundColor) == ComposeUI.Color.green.cgColor expect(renderedEvaluation) !== preparedEvaluation - expect(child.contentView().layer().sublayers?.count) == 1 + expect(child.contentContainerView.layer().sublayers?.count) == 1 } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ReentrantRefreshTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ReentrantRefreshTests.swift index 7ce3c7cb..5d8c8e2f 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ReentrantRefreshTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ReentrantRefreshTests.swift @@ -110,7 +110,7 @@ class ComposeView_ReentrantRefreshTests: XCTestCase { expect(contentMakeCount) == 2 expect(layer) === originalLayer expect(originalLayer.backgroundColor) == Color.red.cgColor - expect(composeView.contentView().layer().sublayers?.count) == 1 + expect(composeView.contentContainerView.layer().sublayers?.count) == 1 // when: the run loop performs the deferred refresh var isDrained = false @@ -122,7 +122,7 @@ class ComposeView_ReentrantRefreshTests: XCTestCase { expect(contentMakeCount) == 3 expect(layer) === originalLayer expect(originalLayer.backgroundColor) == Color.blue.cgColor - expect(composeView.contentView().layer().sublayers?.count) == 1 + expect(composeView.contentContainerView.layer().sublayers?.count) == 1 } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RefreshTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RefreshTests.swift index ffe01758..4419489c 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RefreshTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RefreshTests.swift @@ -30,7 +30,7 @@ import ChouTiTest -import ComposeUI +@testable import ComposeUI import ChouTi class ComposeView_RefreshTests: XCTestCase { @@ -376,13 +376,13 @@ class ComposeView_RefreshTests: XCTestCase { contentView.refresh(animated: false) // then: the subviews are in the content order - expect(contentView.contentView().subviews) == [view1, view2, view3] + expect(contentView.contentContainerView.subviews) == [view1, view2, view3] // when: the view is refreshed again contentView.refresh(animated: false) // then: the subviews keep the same order - expect(contentView.contentView().subviews) == [view1, view2, view3] + expect(contentView.contentContainerView.subviews) == [view1, view2, view3] // when: the content changes the order of the views and the view is refreshed contentView.setContent { @@ -397,6 +397,6 @@ class ComposeView_RefreshTests: XCTestCase { contentView.refresh(animated: false) // then: the subviews are in the new order - expect(contentView.contentView().subviews) == [view3, view2] + expect(contentView.contentContainerView.subviews) == [view3, view2] } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift index 7a99b6bf..ef398626 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift @@ -62,7 +62,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { #if canImport(AppKit) view.scrollIndicatorBehavior = .auto - // use legacy scrollers so the scroller thickness affects bounds(). + // use legacy scrollers so the scroller thickness affects the visible size. view.scrollerStyle = .legacy view.hasHorizontalScroller = true view.hasVerticalScroller = true @@ -75,16 +75,17 @@ class ComposeView_RenderBoundsTests: XCTestCase { view.layoutIfNeeded() // then: the view is rendered with the expected bounds + expect(view.contentOffset) == .zero #if canImport(AppKit) - // after layout, the bounds() should consider the scrollers + // after layout, the visible size should consider the scrollers if #available(macOS 26.0, *) { - expect(view.bounds()) == CGRect(x: 0, y: 0, width: 103, height: 63) + expect(view.visibleSize) == CGSize(width: 103, height: 63) } else { - expect(view.bounds()) == CGRect(x: 0, y: 0, width: 105, height: 65) + expect(view.visibleSize) == CGSize(width: 105, height: 65) } #endif #if canImport(UIKit) - expect(view.bounds()) == CGRect(x: 0, y: 0, width: 120, height: 80) + expect(view.visibleSize) == CGSize(width: 120, height: 80) #endif expect(updateCount) == 1 @@ -117,7 +118,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120, height: 80) // when: adjust scroll position and layout again - view.setContentOffset(CGPoint(x: 0, y: 10)) + view.contentOffset = CGPoint(x: 0, y: 10) view.layoutIfNeeded() // then: should update @@ -152,10 +153,10 @@ class ComposeView_RenderBoundsTests: XCTestCase { useLegacyScrollers(view) view.refresh(animated: false) - view.setContentOffset(CGPoint(x: 0, y: 200)) + view.contentOffset = CGPoint(x: 0, y: 200) view.layoutIfNeeded() expect(view.hasHorizontalScroller) == true - expect(view.contentOffset()) == CGPoint(x: 0, y: 200) + expect(view.contentOffset) == CGPoint(x: 0, y: 200) // when: a refresh shortens the rows, so the clip view clamps the offset while the horizontal scroller shows, and // fits them horizontally, which hides the scroller and clamps the offset again @@ -164,7 +165,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { // then: the pass renders the rows at the offset the view ends up with expect(view.hasHorizontalScroller) == false - expect(view.contentOffset()) == CGPoint(x: 0, y: 150) + expect(view.contentOffset) == CGPoint(x: 0, y: 150) expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 150, width: 100, height: 100) expect(renderedFrames(in: view)) == (15 ..< 25).map { CGRect(x: 0, y: CGFloat($0) * 10, width: 100, height: 10) } } @@ -183,10 +184,10 @@ class ComposeView_RenderBoundsTests: XCTestCase { useLegacyScrollers(view) view.refresh(animated: false) - view.setContentOffset(CGPoint(x: 200, y: 0)) + view.contentOffset = CGPoint(x: 200, y: 0) view.layoutIfNeeded() expect(view.hasVerticalScroller) == true - expect(view.contentOffset()) == CGPoint(x: 200, y: 0) + expect(view.contentOffset) == CGPoint(x: 200, y: 0) // when: a refresh narrows the columns, so the clip view clamps the offset while the vertical scroller shows, and // fits them vertically, which hides the scroller and clamps the offset again @@ -195,7 +196,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { // then: the pass renders the columns at the offset the view ends up with expect(view.hasVerticalScroller) == false - expect(view.contentOffset()) == CGPoint(x: 150, y: 0) + expect(view.contentOffset) == CGPoint(x: 150, y: 0) expect(view.test.lastRenderBounds) == CGRect(x: 150, y: 0, width: 100, height: 100) expect(renderedFrames(in: view)) == (15 ..< 25).map { CGRect(x: CGFloat($0) * 10, y: 0, width: 10, height: 100) } } @@ -214,10 +215,10 @@ class ComposeView_RenderBoundsTests: XCTestCase { useLegacyScrollers(view) view.refresh(animated: false) - view.setContentOffset(CGPoint(x: 0, y: 200)) + view.contentOffset = CGPoint(x: 0, y: 200) view.layoutIfNeeded() expect(view.hasHorizontalScroller) == false - expect(view.contentOffset()) == CGPoint(x: 0, y: 200) + expect(view.contentOffset) == CGPoint(x: 0, y: 200) // when: a refresh shortens the rows, so the clip view clamps the offset, and widens them, which shows the // horizontal scroller and shrinks the clip view @@ -226,7 +227,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { // then: the pass renders the rows at the offset the view ends up with expect(view.hasHorizontalScroller) == true - expect(view.contentOffset()) == CGPoint(x: 0, y: 150) + expect(view.contentOffset) == CGPoint(x: 0, y: 150) expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 150, width: 100, height: 100) expect(renderedFrames(in: view)) == (15 ..< 25).map { CGRect(x: 0, y: CGFloat($0) * 10, width: 200, height: 10) } } @@ -253,11 +254,11 @@ class ComposeView_RenderBoundsTests: XCTestCase { // when: scroll to the end, which the shown scrollers put a scroller thickness further than without them let maxOffsetY = view.maxOffsetY - view.setContentOffset(CGPoint(x: 0, y: maxOffsetY)) + view.contentOffset = CGPoint(x: 0, y: maxOffsetY) view.layoutIfNeeded() // then: the view stays at the end, rendered once for the full view size, with the rows that fill the viewport - expect(view.contentOffset()) == CGPoint(x: 0, y: maxOffsetY) + expect(view.contentOffset) == CGPoint(x: 0, y: maxOffsetY) expect(renderBounds) == [CGRect(x: 0, y: maxOffsetY, width: 100, height: 100)] expect(renderedFrames(in: view)) == (21 ..< 30).map { CGRect(x: 0, y: CGFloat($0) * 10, width: 200, height: 10) } } @@ -284,11 +285,11 @@ class ComposeView_RenderBoundsTests: XCTestCase { } // when: scroll sideways within that thickness - view.setContentOffset(CGPoint(x: 10, y: 50)) + view.contentOffset = CGPoint(x: 10, y: 50) view.layoutIfNeeded() // then: the view stays where it's scrolled to, rendered once for the full view size - expect(view.contentOffset()) == CGPoint(x: 10, y: 50) + expect(view.contentOffset) == CGPoint(x: 10, y: 50) expect(renderBounds) == [CGRect(x: 10, y: 50, width: 100, height: 100)] } @@ -305,9 +306,9 @@ class ComposeView_RenderBoundsTests: XCTestCase { view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) useLegacyScrollers(view) view.refresh(animated: false) - view.setContentOffset(CGPoint(x: 0, y: 200)) + view.contentOffset = CGPoint(x: 0, y: 200) view.layoutIfNeeded() - expect(view.contentOffset()) == CGPoint(x: 0, y: 200) + expect(view.contentOffset) == CGPoint(x: 0, y: 200) var renderBounds: [CGRect] = [] view.onDidRender { _, context in @@ -322,7 +323,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { // then: the view renders once, at the new end, for the full view size expect(view.hasHorizontalScroller) == true expect(view.hasVerticalScroller) == true - expect(view.contentOffset()) == CGPoint(x: 0, y: view.maxOffsetY) + expect(view.contentOffset) == CGPoint(x: 0, y: view.maxOffsetY) expect(renderBounds) == [CGRect(x: 0, y: view.maxOffsetY, width: 100, height: 100)] } @@ -391,7 +392,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { // then: the content lays out for the area inside the tiled border, so it scrolls, with both scrollers shown expect(view.test.lastRenderBounds?.size) == CGSize(width: 236, height: 176) - expect(view.isScrollable) == true + expect(view.isScrollEnabled) == true expect(view.hasHorizontalScroller) == true expect(view.hasVerticalScroller) == true @@ -514,9 +515,9 @@ class ComposeView_RenderBoundsTests: XCTestCase { // then: the rows lay out for the size AppKit rounds the clip view down to, so the content is no wider than the // visible area, and only the vertical scroller shows - expect(view.bounds().size) == CGSize(width: 99, height: 99) + expect(view.visibleSize) == CGSize(width: 99, height: 99) expect(view.test.lastRenderBounds?.size) == CGSize(width: 99, height: 99) - expect(view.contentSize()) == CGSize(width: 99, height: 400) + expect(view.contentSize) == CGSize(width: 99, height: 400) expect(view.hasVerticalScroller) == true expect(view.hasHorizontalScroller) == false } @@ -536,9 +537,9 @@ class ComposeView_RenderBoundsTests: XCTestCase { // then: the content lays out for the size AppKit rounds the clip view up to, which it fits, so the view neither // scrolls nor shows scrollers - expectSize(view.bounds().size, approximatelyEquals: CGSize(width: 100, height: 100)) + expectSize(view.visibleSize, approximatelyEquals: CGSize(width: 100, height: 100)) expectSize(view.test.lastRenderBounds?.size, approximatelyEquals: CGSize(width: 100, height: 100)) - expect(view.isScrollable) == false + expect(view.isScrollEnabled) == false expect(view.hasVerticalScroller) == false expect(view.hasHorizontalScroller) == false } @@ -562,7 +563,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { // then: the content lays out for the clip view's size, so the view scrolls exactly when the content doesn't fit it let clipSize = view.contentView.frame.size expectSize(view.test.lastRenderBounds?.size, approximatelyEquals: clipSize) - expect(view.isScrollable) == (contentLength > clipSize.width) + expect(view.isScrollEnabled) == (contentLength > clipSize.width) } } @@ -598,7 +599,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { } } view.frame = CGRect(x: 150, y: 150, width: 200, height: 100) - parent.contentView().addSubview(view) + parent.contentContainerView.addSubview(view) view.frameRotation = 30 // when: the view refreshes @@ -625,14 +626,14 @@ class ComposeView_RenderBoundsTests: XCTestCase { } view.frame = CGRect(x: 150, y: 150, width: 200, height: 100) useLegacyScrollers(view) - parent.contentView().addSubview(view) + parent.contentContainerView.addSubview(view) view.frameRotation = 30 // when: the view refreshes view.refresh(animated: false) // then: the content fits, so the view neither scrolls nor shows scrollers, and its document is the view's size - expect(view.isScrollable) == false + expect(view.isScrollEnabled) == false expect(view.hasHorizontalScroller) == false expect(view.hasVerticalScroller) == false expectSize(view.contentSize, approximatelyEquals: CGSize(width: 200, height: 100)) @@ -650,9 +651,9 @@ class ComposeView_RenderBoundsTests: XCTestCase { view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) useLegacyScrollers(view) view.refresh(animated: false) - view.setContentOffset(CGPoint(x: 0, y: 290)) + view.contentOffset = CGPoint(x: 0, y: 290) view.layoutIfNeeded() - expect(view.contentOffset()) == CGPoint(x: 0, y: 290) + expect(view.contentOffset) == CGPoint(x: 0, y: 290) var renderBounds: [CGRect] = [] view.onDidRender { _, context in @@ -665,7 +666,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { view.layoutIfNeeded() // then: the view renders once, after the tiling, for the new size at the clamped offset - expect(view.contentOffset()) == CGPoint(x: 0, y: view.maxOffsetY) + expect(view.contentOffset) == CGPoint(x: 0, y: view.maxOffsetY) expect(renderBounds) == [CGRect(x: 0, y: view.maxOffsetY, width: 140, height: 140)] } @@ -677,7 +678,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { /// The frames of the rendered layers, ordered from top to bottom, then from left to right. private func renderedFrames(in view: ComposeView) -> [CGRect] { - let frames = view.contentView().layer?.sublayers?.map(\.frame) ?? [] + let frames = view.contentContainerView.layer?.sublayers?.map(\.frame) ?? [] return frames.sorted { ($0.minY, $0.minX) < ($1.minY, $1.minX) } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderFrameUpdateTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderFrameUpdateTests.swift index c31913d5..5844e34c 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderFrameUpdateTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderFrameUpdateTests.swift @@ -65,7 +65,7 @@ class ComposeView_RenderFrameUpdateTests: XCTestCase { expect(tracked.frame) == CGRect(x: 0, y: 0, width: 100, height: 50) // when: scroll a little, the tracking row stays visible and its content-space frame is unchanged - view.setContentOffset(CGPoint(x: 0, y: 10)) + view.contentOffset = CGPoint(x: 0, y: 10) view.layoutIfNeeded() // then: the reused renderable still has the correct frame @@ -87,7 +87,7 @@ class ComposeView_RenderFrameUpdateTests: XCTestCase { tracked.resetFrameSetCount() // when: scroll a little, the tracking row stays visible and its content-space frame is unchanged - view.setContentOffset(CGPoint(x: 0, y: 10)) + view.contentOffset = CGPoint(x: 0, y: 10) view.layoutIfNeeded() // then: the frame did not change, so the render pass should not have re-applied it @@ -115,7 +115,7 @@ class ComposeView_RenderFrameUpdateTests: XCTestCase { tracked.resetFrameSetCount() // when: scroll a little, the tracking row stays visible and its content-space frame is unchanged - view.setContentOffset(CGPoint(x: 0, y: 10)) + view.contentOffset = CGPoint(x: 0, y: 10) view.layoutIfNeeded() // then: the frame is recognized as unchanged despite the imprecise derived frame, so it is not re-applied @@ -294,7 +294,7 @@ class ComposeView_RenderFrameUpdateTests: XCTestCase { expect(tracked.frame) == CGRect(x: 0, y: 10, width: 100, height: 50) // when: scrolling a little, the row stays visible and keeps its content-space frame - view.setContentOffset(CGPoint(x: 0, y: 10)) + view.contentOffset = CGPoint(x: 0, y: 10) view.layoutIfNeeded() // then: the animated scroll pass adds no frame animations to the reused row @@ -320,7 +320,7 @@ class ComposeView_RenderFrameUpdateTests: XCTestCase { expect(CATransform3DIsIdentity(layer.transform)) == false // when: the layer is reused via scroll - view.setContentOffset(CGPoint(x: 0, y: 10)) + view.contentOffset = CGPoint(x: 0, y: 10) view.layoutIfNeeded() // then: the render pass resets the transform to identity before applying the frame @@ -340,7 +340,7 @@ class ComposeView_RenderFrameUpdateTests: XCTestCase { expect(CATransform3DIsIdentity(layer.transform)) == true // when: the layer is reused via scroll - view.setContentOffset(CGPoint(x: 0, y: 10)) + view.contentOffset = CGPoint(x: 0, y: 10) view.layoutIfNeeded() // then: an already-identity transform stays identity @@ -469,7 +469,7 @@ class ComposeView_RenderFrameUpdateTests: XCTestCase { tracked.resetFrameSetCount() // when: the row is reused by a scroll and by a refresh - view.setContentOffset(CGPoint(x: 0, y: 10)) + view.contentOffset = CGPoint(x: 0, y: 10) view.layoutIfNeeded() view.refresh(animated: false) diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift index 27984adb..6acebf2c 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift @@ -191,7 +191,7 @@ class ComposeView_RenderHandlerTests: XCTestCase { didRenderRenderBounds = nil didRenderRenderType = nil - view.setContentOffset(CGPoint(x: 0, y: 50)) + view.contentOffset = CGPoint(x: 0, y: 50) view.layoutIfNeeded() // then: all handlers should be called with scroll render type @@ -263,7 +263,7 @@ class ComposeView_RenderHandlerTests: XCTestCase { willRenderRenderType = context.renderType // when: adjust the content offset to be at the bottom - view.setContentOffset(CGPoint(x: 0, y: 100)) + view.contentOffset = CGPoint(x: 0, y: 100) } var requestedVisibleBounds: CGRect? @@ -287,13 +287,13 @@ class ComposeView_RenderHandlerTests: XCTestCase { expect(willRenderRenderType) == .refresh(isAnimated: false) // then: the content offset should be updated - expect(view.contentOffset().y) == 100 + expect(view.contentOffset.y) == 100 expect(eventOrder) == ["willRender", "renderItems"] expect(requestedVisibleBounds) == CGRect(x: 0, y: 100, width: 100, height: 100) // when: the view is scrolled - view.setContentOffset(CGPoint(x: 0, y: 10)) + view.contentOffset = CGPoint(x: 0, y: 10) view.layoutIfNeeded() // then: the will-render handler should be called with the correct arguments @@ -301,16 +301,18 @@ class ComposeView_RenderHandlerTests: XCTestCase { expect(willRenderContentSize) == CGSize(width: 100, height: 200) expect(willRenderRenderBounds) == CGRect(x: 0, y: 10, width: 100, height: 100) expect(willRenderRenderType) == .boundsChange(previousBounds: CGRect(x: 0, y: 100, width: 100, height: 100), bounds: CGRect(x: 0, y: 10, width: 100, height: 100)) - expect(view.contentOffset().y) == 100 + expect(view.contentOffset.y) == 100 expect(eventOrder) == ["willRender", "renderItems", "willRender", "renderItems"] expect(requestedVisibleBounds) == CGRect(x: 0, y: 100, width: 100, height: 100) - expect(view.bounds()) == CGRect(x: 0, y: 100, width: 100, height: 100) + expect(view.contentOffset) == CGPoint(x: 0, y: 100) + expect(view.visibleSize) == CGSize(width: 100, height: 100) // when: the view is resized view.frame.size = CGSize(width: 150, height: 150) - expect(view.bounds()) == CGRect(x: 0, y: 50, width: 150, height: 150) // y: 50 (maxOffsetY) = 200 - 150 + expect(view.contentOffset) == CGPoint(x: 0, y: 50) // y: 50 (maxOffsetY) = 200 - 150 + expect(view.visibleSize) == CGSize(width: 150, height: 150) view.layoutIfNeeded() @@ -320,10 +322,10 @@ class ComposeView_RenderHandlerTests: XCTestCase { expect(willRenderRenderBounds) == CGRect(x: 0, y: 50, width: 150, height: 150) expect(willRenderRenderType) == .boundsChange(previousBounds: CGRect(x: 0, y: 100, width: 100, height: 100), bounds: CGRect(x: 0, y: 50, width: 150, height: 150)) #if canImport(AppKit) - expect(view.contentOffset().y) == 50 // AppKit doesn't allow over scroll + expect(view.contentOffset.y) == 50 // AppKit doesn't allow over scroll #endif #if canImport(UIKit) - expect(view.contentOffset().y) == 100 + expect(view.contentOffset.y) == 100 #endif expect(eventOrder) == [ @@ -363,7 +365,7 @@ class ComposeView_RenderHandlerTests: XCTestCase { view.onWillRender { contentView, context in willRenderContext = context if let adjustedOffset { - contentView.setContentOffset(CGPoint(x: 0, y: adjustedOffset)) + contentView.contentOffset = CGPoint(x: 0, y: adjustedOffset) } } view.onDidRender { _, context in @@ -430,7 +432,7 @@ class ComposeView_RenderHandlerTests: XCTestCase { // when: a later scroll uses the completed adjusted viewport as history adjustedOffset = nil animationTypes.removeAll() - view.setContentOffset(CGPoint(x: 0, y: 60)) + view.contentOffset = CGPoint(x: 0, y: 60) view.layoutIfNeeded() // then: scrolling uses the same public case and the dynamic policy can reject its new bounds @@ -458,7 +460,7 @@ class ComposeView_RenderHandlerTests: XCTestCase { LayerNode(update: { renderable, context in if index == 0, changesOffset { changesOffset = false - context.contentView.setContentOffset(CGPoint(x: 0, y: 40)) + context.contentView.contentOffset = CGPoint(x: 0, y: 40) } renderable.cornerRadius = context.renderBounds.minY layers.append(renderable) @@ -490,7 +492,7 @@ class ComposeView_RenderHandlerTests: XCTestCase { let initialBounds = CGRect(x: 0, y: 0, width: 100, height: 100) let passBounds = CGRect(x: 0, y: 0, width: 150, height: 100) let expectedType = ComposeView.RenderType.boundsChange(previousBounds: initialBounds, bounds: passBounds) - expect(view.contentOffset().y) == 40 + expect(view.contentOffset.y) == 40 expect(animationTypes) == [expectedType] expect(didRenderContext?.renderType) == expectedType expect(didRenderContext?.renderBounds) == passBounds @@ -561,7 +563,8 @@ class ComposeView_RenderHandlerTests: XCTestCase { // set up the will-render handler that changes the bounds size view.onWillRender { view, _ in - view.setBounds(CGRect(x: 0, y: 10, width: 150, height: 150)) + view.frame.size = CGSize(width: 150, height: 150) + view.contentOffset = CGPoint(x: 0, y: 10) } // when: the view is refreshed @@ -604,7 +607,8 @@ class ComposeView_RenderHandlerTests: XCTestCase { return } changesBounds = false - view.setBounds(CGRect(x: 0, y: 10, width: 160, height: 100)) + view.frame.size = CGSize(width: 160, height: 100) + view.contentOffset = CGPoint(x: 0, y: 10) view.setNeedsLayout() view.layoutIfNeeded() } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderReuseTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderReuseTests.swift index 3c33ee13..27ad6003 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderReuseTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderReuseTests.swift @@ -1203,18 +1203,18 @@ class ComposeView_RenderReuseTests: XCTestCase { private func scrollDown(_ view: ComposeView) { var offset: CGFloat = 0 while offset <= Constants.maxOffset { - view.setContentOffset(CGPoint(x: 0, y: offset)) + view.contentOffset = CGPoint(x: 0, y: offset) view.layoutIfNeeded() offset += Constants.rowHeight } } private func visibleRowViews(in view: ComposeView) -> [ReuseTrackingView] { - view.contentView().subviews.compactMap { $0 as? ReuseTrackingView } + view.contentContainerView.subviews.compactMap { $0 as? ReuseTrackingView } } private func visibleRowTypeNames(in view: ComposeView) -> Set { - Set(view.contentView().subviews.map { String(describing: type(of: $0)) }) + Set(view.contentContainerView.subviews.map { String(describing: type(of: $0)) }) } private func firstRenderableItem(of node: some ComposeNode) -> RenderableItem? { @@ -1238,15 +1238,15 @@ class ComposeView_RenderReuseTests: XCTestCase { } private func firstBaseTextView(in view: ComposeView) -> BaseTextView? { - view.contentView().subviews.compactMap { $0 as? BaseTextView }.first + view.contentContainerView.subviews.compactMap { $0 as? BaseTextView }.first } private func contentSublayers(in view: ComposeView) -> [CALayer]? { #if canImport(AppKit) - return view.contentView().layer?.sublayers + return view.contentContainerView.layer?.sublayers #endif #if canImport(UIKit) - return view.contentView().layer.sublayers + return view.contentContainerView.layer.sublayers #endif } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableTests.swift index 35796195..c1bd136b 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableTests.swift @@ -103,7 +103,7 @@ class ComposeView_RenderableTests: XCTestCase { #if canImport(AppKit) view.scrollIndicatorBehavior = .auto - // use legacy scrollers so the scroller thickness affects bounds(). + // use legacy scrollers so the scroller thickness affects the visible size. view.scrollerStyle = .legacy view.hasHorizontalScroller = true view.hasVerticalScroller = true @@ -187,7 +187,7 @@ class ComposeView_RenderableTests: XCTestCase { expect(willUpdateContext?.contentEvaluation) === refreshedEvaluation // when: scroll the view - view.setContentOffset(CGPoint(x: 0, y: 10)) + view.contentOffset = CGPoint(x: 0, y: 10) view.layoutIfNeeded() // then: expect the update context is correct, a scroll pass allows transitions only by default @@ -229,7 +229,7 @@ class ComposeView_RenderableTests: XCTestCase { oldFrame: CGRect(x: 0, y: 0, width: 100, height: 200), newFrame: CGRect(x: 50, y: 0, width: 100, height: 200), previousRenderBounds: CGRect(x: 0, y: 10, width: 100, height: 150), - renderBounds: CGRect(origin: view.contentOffset(), size: CGSize(width: 200, height: 200)), + renderBounds: CGRect(origin: view.contentOffset, size: CGSize(width: 200, height: 200)), animationTiming: nil, contentView: view, contentEvaluation: nil, @@ -240,7 +240,7 @@ class ComposeView_RenderableTests: XCTestCase { oldFrame: CGRect(x: 0, y: 0, width: 100, height: 200), newFrame: CGRect(x: 50, y: 0, width: 100, height: 200), previousRenderBounds: CGRect(x: 0, y: 10, width: 100, height: 150), - renderBounds: CGRect(origin: view.contentOffset(), size: CGSize(width: 200, height: 200)), + renderBounds: CGRect(origin: view.contentOffset, size: CGSize(width: 200, height: 200)), animationTiming: nil, contentView: view, contentEvaluation: nil, diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableUpdateBoundsTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableUpdateBoundsTests.swift index 781ca356..41d621f6 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableUpdateBoundsTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableUpdateBoundsTests.swift @@ -79,7 +79,7 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { expect(layer.backgroundColor) == Color.red.cgColor // when: only the viewport origin changes - view.setContentOffset(CGPoint(x: 0, y: 20)) + view.contentOffset = CGPoint(x: 0, y: 20) view.layoutIfNeeded() let scrolledBounds = CGRect(x: 0, y: 20, width: 100, height: 100) @@ -114,7 +114,7 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { // when: a resize and an offset adjustment happen in the same pass view.onWillRender { contentView, _ in - contentView.setContentOffset(CGPoint(x: 0, y: 40)) + contentView.contentOffset = CGPoint(x: 0, y: 40) } view.frame.size.width = 180 view.setNeedsLayout() @@ -155,7 +155,7 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { // when: a pending non-animated refresh is combined with a resize and scroll color = .blue view.onWillRender { contentView, _ in - contentView.setContentOffset(CGPoint(x: 0, y: 30)) + contentView.contentOffset = CGPoint(x: 0, y: 30) } view.setNeedsRefresh(animated: false) view.frame.size.width = 150 @@ -202,7 +202,7 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) view.visibleBoundsInsets = EdgeInsets(top: -20, left: -10, bottom: -30, right: -10) view.onWillRender { contentView, _ in - contentView.setContentOffset(CGPoint(x: 0, y: 50)) + contentView.contentOffset = CGPoint(x: 0, y: 50) } // when: the adjusted viewport is rendered @@ -240,7 +240,7 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { LayerNode(update: { layer, context in if index == 0, changesOffset { changesOffset = false - context.contentView.setContentOffset(CGPoint(x: 0, y: 40)) + context.contentView.contentOffset = CGPoint(x: 0, y: 40) } layer.cornerRadius = context.renderBounds.minY contexts.append(context) @@ -403,7 +403,7 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { expect(layer) == nil // when: scrolling reveals the layer - view.setContentOffset(CGPoint(x: 0, y: 200)) + view.contentOffset = CGPoint(x: 0, y: 200) view.layoutIfNeeded() let originalLayer = try unwrap(layer) @@ -414,11 +414,11 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { expect(originalLayer.backgroundColor) == Color.blue.cgColor // when: the layer is removed and revealed again - view.setContentOffset(CGPoint(x: 0, y: 350)) + view.contentOffset = CGPoint(x: 0, y: 350) view.layoutIfNeeded() expect(originalLayer.superlayer) == nil context = nil - view.setContentOffset(CGPoint(x: 0, y: 200)) + view.contentOffset = CGPoint(x: 0, y: 200) view.layoutIfNeeded() // then: a later insertion still reports the immediately preceding viewport @@ -462,7 +462,7 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { expect(childContext?.renderBounds) == CGRect(x: 0, y: 0, width: 100, height: 100) // when: only the child scrolls - child.setContentOffset(CGPoint(x: 0, y: 30)) + child.contentOffset = CGPoint(x: 0, y: 30) child.layoutIfNeeded() // then: the child's snapshots and output reflect its independent position @@ -509,7 +509,7 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { requestsRefresh = true // when: scrolling starts the pass that requests another refresh - view.setContentOffset(CGPoint(x: 0, y: 20)) + view.contentOffset = CGPoint(x: 0, y: 20) view.layoutIfNeeded() expect(contexts.last?.updateType).toEventually(beEqual(to: .refresh)) @@ -548,7 +548,7 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { expect(firstLayer.frame) == CGRect(x: 0, y: 0, width: 100, height: 100) // when: scrolling replaces the visible row - view.setContentOffset(CGPoint(x: 0, y: 200)) + view.contentOffset = CGPoint(x: 0, y: 200) view.layoutIfNeeded() // then: the recycled layer is initialized with the new row's color and the host's viewports, not its own frames @@ -582,11 +582,11 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { let originalLayer = try unwrap(layer) // when: the row scrolls out of view and returns before removal completes - view.setContentOffset(CGPoint(x: 0, y: 200)) + view.contentOffset = CGPoint(x: 0, y: 200) view.layoutIfNeeded() expect(originalLayer.superlayer) != nil context = nil - view.setContentOffset(.zero) + view.contentOffset = .zero view.layoutIfNeeded() // then: the revived layer receives insertion with the immediately preceding viewport @@ -615,7 +615,7 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { } view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) view.refresh(animated: false) - view.setContentOffset(CGPoint(x: 0, y: 350)) + view.contentOffset = CGPoint(x: 0, y: 350) view.layoutIfNeeded() let previousBounds = CGRect(x: 0, y: 350, width: 100, height: 100) expect(try unwrap(context).renderBounds) == previousBounds @@ -627,7 +627,7 @@ class ComposeView_RenderableUpdateBoundsTests: XCTestCase { // then: the scroll view clamps the offset to the new content size, and the pass renders the clamped viewport let clampedBounds = CGRect(x: 0, y: 50, width: 100, height: 100) - expect(view.contentOffset()) == clampedBounds.origin + expect(view.contentOffset) == clampedBounds.origin let update = try unwrap(context) expect(update.updateType) == .refresh expect(update.previousRenderBounds) == previousBounds diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollBehaviorTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollBehaviorTests.swift index 61119e30..9e32c1e4 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollBehaviorTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollBehaviorTests.swift @@ -45,14 +45,14 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { contentView.refresh(animated: false) // then: the view is not scrollable since the content size is smaller than bounds size - expect(contentView.isScrollable) == false + expect(contentView.isScrollEnabled) == false // when: set to always scrollable contentView.scrollBehavior = .always contentView.refresh(animated: false) // then: the view is scrollable and always bounces - expect(contentView.isScrollable) == true + expect(contentView.isScrollEnabled) == true expect(contentView.alwaysBounceHorizontal) == true expect(contentView.alwaysBounceVertical) == true @@ -61,7 +61,7 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { contentView.refresh(animated: false) // then: the view is not scrollable and does not bounce - expect(contentView.isScrollable) == false + expect(contentView.isScrollEnabled) == false expect(contentView.alwaysBounceHorizontal) == false expect(contentView.alwaysBounceVertical) == false @@ -70,19 +70,19 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { contentView.refresh(animated: false) // then: the view's scrollable behavior is not changed - expect(contentView.isScrollable) == false + expect(contentView.isScrollEnabled) == false expect(contentView.alwaysBounceHorizontal) == false expect(contentView.alwaysBounceVertical) == false // when: manually set the scrollable behavior and refresh - contentView.isScrollable = true + contentView.isScrollEnabled = true contentView.alwaysBounceHorizontal = true contentView.alwaysBounceVertical = true contentView.refresh(animated: false) // then: the view's scrollable behavior is not changed after refresh - expect(contentView.isScrollable) == true + expect(contentView.isScrollEnabled) == true expect(contentView.alwaysBounceHorizontal) == true expect(contentView.alwaysBounceVertical) == true } @@ -97,14 +97,14 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { contentView.refresh(animated: false) // then: the view is not scrollable since the content size is equal to bounds size - expect(contentView.isScrollable) == false + expect(contentView.isScrollEnabled) == false // when: set to always scrollable contentView.scrollBehavior = .always contentView.refresh(animated: false) // then: the view is scrollable and always bounces - expect(contentView.isScrollable) == true + expect(contentView.isScrollEnabled) == true expect(contentView.alwaysBounceHorizontal) == true expect(contentView.alwaysBounceVertical) == true @@ -113,7 +113,7 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { contentView.refresh(animated: false) // then: the view is not scrollable and does not bounce - expect(contentView.isScrollable) == false + expect(contentView.isScrollEnabled) == false expect(contentView.alwaysBounceHorizontal) == false expect(contentView.alwaysBounceVertical) == false @@ -122,19 +122,19 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { contentView.refresh(animated: false) // then: the view's scrollable behavior is not changed - expect(contentView.isScrollable) == false + expect(contentView.isScrollEnabled) == false expect(contentView.alwaysBounceHorizontal) == false expect(contentView.alwaysBounceVertical) == false // when: manually set the scrollable behavior and refresh - contentView.isScrollable = true + contentView.isScrollEnabled = true contentView.alwaysBounceHorizontal = true contentView.alwaysBounceVertical = true contentView.refresh(animated: false) // then: the view's scrollable behavior is not changed after refresh - expect(contentView.isScrollable) == true + expect(contentView.isScrollEnabled) == true expect(contentView.alwaysBounceHorizontal) == true expect(contentView.alwaysBounceVertical) == true } @@ -149,14 +149,14 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { contentView.refresh(animated: false) // then: the view is scrollable since the content size is larger than bounds size - expect(contentView.isScrollable) == true + expect(contentView.isScrollEnabled) == true // when: set to never scrollable contentView.scrollBehavior = .never contentView.refresh(animated: false) // then: the view is not scrollable and does not bounce - expect(contentView.isScrollable) == false + expect(contentView.isScrollEnabled) == false expect(contentView.alwaysBounceHorizontal) == false expect(contentView.alwaysBounceVertical) == false @@ -165,7 +165,7 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { contentView.refresh(animated: false) // then: the view is scrollable and always bounces - expect(contentView.isScrollable) == true + expect(contentView.isScrollEnabled) == true expect(contentView.alwaysBounceHorizontal) == true expect(contentView.alwaysBounceVertical) == true @@ -174,19 +174,19 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { contentView.refresh(animated: false) // then: the view's scrollable behavior is not changed - expect(contentView.isScrollable) == true + expect(contentView.isScrollEnabled) == true expect(contentView.alwaysBounceHorizontal) == true expect(contentView.alwaysBounceVertical) == true // when: manually set the scrollable behavior and refresh - contentView.isScrollable = false + contentView.isScrollEnabled = false contentView.alwaysBounceHorizontal = false contentView.alwaysBounceVertical = false contentView.refresh(animated: false) // then: the view's scrollable behavior is not changed after refresh - expect(contentView.isScrollable) == false + expect(contentView.isScrollEnabled) == false expect(contentView.alwaysBounceHorizontal) == false expect(contentView.alwaysBounceVertical) == false } @@ -217,7 +217,7 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { expect(contentWidth).to(beApproximatelyEqual(to: 100, within: 1e-9)) // then: the content fits, so the view neither scrolls nor clips, and has nothing to scroll horizontally - expect(contentView.isScrollable) == false + expect(contentView.isScrollEnabled) == false expect(contentView.clipsToBounds) == false expect(contentView.contentSize.width) == 100 } @@ -235,7 +235,7 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { contentView.refresh(animated: false) // then: the view scrolls, but its content is as wide as the view, so it scrolls only vertically - expect(contentView.isScrollable) == true + expect(contentView.isScrollEnabled) == true expect(contentView.contentSize) == CGSize(width: 100, height: 300) #if canImport(AppKit) @@ -260,7 +260,7 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { contentView.refresh(animated: false) // then: the content overflows, so the view scrolls and clips - expect(contentView.isScrollable) == true + expect(contentView.isScrollEnabled) == true expect(contentView.clipsToBounds) == true } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ZOrderTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ZOrderTests.swift index 906e7edf..1360e3d8 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ZOrderTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ZOrderTests.swift @@ -103,7 +103,7 @@ class ComposeView_ZOrderTests: XCTestCase { hostWindow?.displayIfNeeded() #endif - let contentView: View = view.contentView() + let contentView = view.contentContainerView let ids = recorder.renderableItemIds // all renderables must be in the hierarchy @@ -223,7 +223,7 @@ class ComposeView_ZOrderTests: XCTestCase { var offset: CGFloat = 0 for _ in 0 ..< 30 { offset += 35 - view.setContentOffset(CGPoint(x: 0, y: offset)) + view.contentOffset = CGPoint(x: 0, y: offset) view.layoutIfNeeded() expectHierarchyMatchesItemOrder(view, recorder) } @@ -243,7 +243,7 @@ class ComposeView_ZOrderTests: XCTestCase { // start scrolled down, then scroll up so that new rows enter at the back of the z-order var offset: CGFloat = 1050 - view.setContentOffset(CGPoint(x: 0, y: offset)) + view.contentOffset = CGPoint(x: 0, y: offset) view.layoutIfNeeded() expectHierarchyMatchesItemOrder(view, recorder) @@ -251,7 +251,7 @@ class ComposeView_ZOrderTests: XCTestCase { // then: the hierarchy matches the items order after each step for _ in 0 ..< 30 { offset -= 35 - view.setContentOffset(CGPoint(x: 0, y: offset)) + view.contentOffset = CGPoint(x: 0, y: offset) view.layoutIfNeeded() expectHierarchyMatchesItemOrder(view, recorder) } @@ -412,7 +412,7 @@ class ComposeView_ZOrderTests: XCTestCase { // then: the in-transition removing views keep their z-positions relative to the reused views: // "r1" stays at the back, "r2" stays above "b" and below "c" - let subviews = view.contentView().subviews + let subviews = view.contentContainerView.subviews guard let removingIndex1 = subviews.firstIndex(of: removingView1), let removingIndex2 = subviews.firstIndex(of: removingView2), let indexB = subviews.firstIndex(of: viewB), @@ -458,7 +458,7 @@ class ComposeView_ZOrderTests: XCTestCase { let expectedLayers = expectedIds.compactMap { recorder.renderableMap[$0]?.layer } expect(expectedLayers.count) == 5 let renderableLayerIds = Set(expectedLayers.map { ObjectIdentifier($0) }) - let actualLayers = Self.effectiveRenderOrder(of: view.contentView().layer()).filter { renderableLayerIds.contains(ObjectIdentifier($0)) } + let actualLayers = Self.effectiveRenderOrder(of: view.contentContainerView.layer()).filter { renderableLayerIds.contains(ObjectIdentifier($0)) } expect(actualLayers.map { ObjectIdentifier($0) }) == expectedLayers.map { ObjectIdentifier($0) } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift index 3835192f..7598472d 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift @@ -209,7 +209,7 @@ class ComposeViewTests: XCTestCase { expect(layer?.backgroundColor) == Color.red.cgColor // when: the displayed content scrolls and resizes after measurement - view.setContentOffset(CGPoint(x: 0, y: 20)) + view.contentOffset = CGPoint(x: 0, y: 20) view.layoutIfNeeded() view.frame.size.width = 150 view.setNeedsLayout() @@ -344,12 +344,12 @@ class ComposeViewTests: XCTestCase { // then: the nested content renders the mounted proposal rather than the measurement proposal expect(nestedView) === nested expect(layer) === originalLayer - expect(nested.bounds().size) == CGSize(width: 180, height: 300) + expect(nested.visibleSize) == CGSize(width: 180, height: 300) expect(layer?.frame) == CGRect(x: 0, y: 0, width: 180, height: 300) expect(layer?.backgroundColor) == Color.red.cgColor // when: the outer view scrolls without another layout proposal - view.setContentOffset(CGPoint(x: 0, y: 20)) + view.contentOffset = CGPoint(x: 0, y: 20) view.layoutIfNeeded() // then: the nested frames remain consistent diff --git a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTypeTests.swift b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollView+ScrollingTests.swift similarity index 68% rename from ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTypeTests.swift rename to ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollView+ScrollingTests.swift index d0d356fe..f14cb9f2 100644 --- a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTypeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollView+ScrollingTests.swift @@ -1,8 +1,8 @@ // -// ScrollViewTypeTests.swift +// ScrollView+ScrollingTests.swift // ComposéUI // -// Created by Honghao Zhang on 2/20/26. +// Created by Honghao Zhang on 9/30/26. // Copyright © 2024 Honghao Zhang. // // MIT License @@ -40,37 +40,14 @@ import ChouTiTest import ComposeUI -class ScrollViewTypeTests: XCTestCase { - - func test_contentInsets() { - // given: a scroll view with a frame and content size - let scrollView = ScrollView() - scrollView.frame = CGRect(x: 0, y: 0, width: 100, height: 200) - scrollView.contentSize = CGSize(width: 300, height: 500) - - // when: setting the content insets - scrollView.setContentInsets(EdgeInsets(top: 10, left: 20, bottom: 30, right: 40)) - - // then: with automatic inset adjustment disabled, the content insets read back the set values - #if canImport(AppKit) - scrollView.automaticallyAdjustsContentInsets = false - expect(scrollView.contentInsets().top) == 10 - expect(scrollView.contentInsets().left) == 20 - expect(scrollView.contentInsets().bottom) == 30 - expect(scrollView.contentInsets().right) == 40 - #endif - #if canImport(UIKit) - scrollView.contentInsetAdjustmentBehavior = .never - expect(scrollView.contentInsets()) == EdgeInsets(top: 10, left: 20, bottom: 30, right: 40) - #endif - } +class ScrollView_ScrollingTests: XCTestCase { func test_offsets() { // given: a scroll view with content size and insets, automatic inset adjustment disabled let scrollView = ScrollView() scrollView.frame = CGRect(x: 0, y: 0, width: 100, height: 200) scrollView.contentSize = CGSize(width: 300, height: 500) - scrollView.setContentInsets(EdgeInsets(top: 10, left: 20, bottom: 30, right: 40)) + scrollView.contentInset = EdgeInsets(top: 10, left: 20, bottom: 30, right: 40) #if canImport(AppKit) scrollView.automaticallyAdjustsContentInsets = false #endif @@ -90,7 +67,7 @@ class ScrollViewTypeTests: XCTestCase { let scrollView = ScrollView() scrollView.frame = CGRect(x: 0, y: 0, width: 100, height: 100) scrollView.contentSize = CGSize(width: 180, height: 220) - scrollView.setContentInsets(EdgeInsets(top: 8, left: 6, bottom: 4, right: 2)) + scrollView.contentInset = EdgeInsets(top: 8, left: 6, bottom: 4, right: 2) #if canImport(AppKit) scrollView.automaticallyAdjustsContentInsets = false #endif @@ -99,7 +76,7 @@ class ScrollViewTypeTests: XCTestCase { #endif // when: scrolling to the minimum offset - scrollView.setContentOffset(CGPoint(x: scrollView.minOffsetX, y: scrollView.minOffsetY)) + scrollView.contentOffset = CGPoint(x: scrollView.minOffsetX, y: scrollView.minOffsetY) // then: it can only scroll to the right and to the bottom expect(scrollView.canScrollToLeft) == false @@ -108,7 +85,7 @@ class ScrollViewTypeTests: XCTestCase { expect(scrollView.canScrollToBottom) == true // when: scrolling to the maximum offset - scrollView.setContentOffset(CGPoint(x: scrollView.maxOffsetX, y: scrollView.maxOffsetY)) + scrollView.contentOffset = CGPoint(x: scrollView.maxOffsetX, y: scrollView.maxOffsetY) // then: it can only scroll to the left and to the top expect(scrollView.canScrollToLeft) == true diff --git a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift index 73e990c6..ea094674 100644 --- a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift +++ b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift @@ -30,13 +30,242 @@ #if canImport(AppKit) import AppKit +#endif + +#if canImport(UIKit) +import UIKit +#endif import ChouTiTest -import ComposeUI +@testable import ComposeUI class ScrollViewTests: XCTestCase { + // MARK: - Content Offset + + func test_contentOffset() { + // given: a 100 × 200 scroll view showing a 300 × 500 content + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + scrollView.contentSize = CGSize(width: 300, height: 500) + + // when: setting the content offset + scrollView.contentOffset = CGPoint(x: 10, y: 20) + + // then: the visible area starts at the offset + expect(scrollView.contentOffset) == CGPoint(x: 10, y: 20) + #if canImport(AppKit) + expect(scrollView.contentView.bounds.origin) == CGPoint(x: 10, y: 20) + #endif + #if canImport(UIKit) + expect(scrollView.bounds.origin) == CGPoint(x: 10, y: 20) + #endif + } + + func test_contentOffset_outsideScrollableRange() { + // given: a 100 × 200 scroll view showing a 300 × 500 content + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + scrollView.contentSize = CGSize(width: 300, height: 500) + + // when: setting a content offset past the start of the horizontal range and the end of the vertical range + scrollView.contentOffset = CGPoint(x: -50, y: 1000) + + // then: AppKit keeps the offset within the scrollable range, and UIKit keeps it as set + #if canImport(AppKit) + expect(scrollView.contentOffset) == CGPoint(x: 0, y: 300) + #endif + #if canImport(UIKit) + expect(scrollView.contentOffset) == CGPoint(x: -50, y: 1000) + #endif + } + + // MARK: - Content Size + + func test_contentSize() { + // given: a 100 × 200 scroll view + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + + // when: setting the content size + scrollView.contentSize = CGSize(width: 300, height: 500) + + // then: the content has the size + expect(scrollView.contentSize) == CGSize(width: 300, height: 500) + #if canImport(AppKit) + expect(scrollView.documentView?.frame) == CGRect(x: 0, y: 0, width: 300, height: 500) + #endif + } + + // MARK: - Content Inset + + func test_contentInset() { + // given: a scroll view without automatic inset adjustments + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + #if canImport(AppKit) + scrollView.automaticallyAdjustsContentInsets = false + #endif + #if canImport(UIKit) + scrollView.contentInsetAdjustmentBehavior = .never + #endif + + // when: setting the content inset + scrollView.contentInset = EdgeInsets(top: 10, left: 20, bottom: 30, right: 40) + + // then: the content inset and the adjusted content inset are the set insets + expect(Self.components(of: scrollView.contentInset)) == [10, 20, 30, 40] + expect(Self.components(of: scrollView.adjustedContentInset)) == [10, 20, 30, 40] + #if canImport(AppKit) + expect(Self.components(of: scrollView.contentInsets)) == [10, 20, 30, 40] + #endif + } + + #if canImport(AppKit) + func test_adjustedContentInset_automaticAdjustment() { + // given: a scroll view with automatic inset adjustments, filling a window whose title bar and toolbar overlap it + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.toolbar = NSToolbar(identifier: "ScrollViewTests") + let scrollView = ScrollView(frame: .zero) + scrollView.automaticallyAdjustsContentInsets = true + + // when: the scroll view is placed in the window and the window lays out + window.contentView = scrollView + window.layoutIfNeeded() + + // then: AppKit applies the adjustment to `contentInsets`, so both the content inset and the adjusted content inset + // include the overlap + let overlap = scrollView.bounds.height - window.contentLayoutRect.height + expect(overlap) > 0 + expect(scrollView.adjustedContentInset.top) == overlap + expect(scrollView.contentInset.top) == overlap + } + #endif + + // MARK: - Visible Size + + func test_visibleSize() { + // given: a 100 × 200 scroll view showing a 300 × 500 content + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + scrollView.contentSize = CGSize(width: 300, height: 500) + + // when: scrolling + scrollView.contentOffset = CGPoint(x: 10, y: 20) + + // then: the visible area has the scroll view's size + expect(scrollView.visibleSize) == CGSize(width: 100, height: 200) + } + + #if canImport(AppKit) + func test_visibleSize_legacyScroller() { + // given: a 100 × 200 scroll view showing content taller than it, with a legacy vertical scroller + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + scrollView.contentSize = CGSize(width: 100, height: 500) + scrollView.scrollerStyle = .legacy + scrollView.hasVerticalScroller = true + + // when: the scroll view tiles + scrollView.tile() + + // then: the scroller takes its width from the visible area + let scrollerWidth = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + expect(scrollerWidth) > 0 + expect(scrollView.visibleSize) == CGSize(width: 100 - scrollerWidth, height: 200) + } + + func test_visibleSize_magnification() { + // given: a 100 × 200 scroll view showing a 300 × 500 content + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + scrollView.contentSize = CGSize(width: 300, height: 500) + scrollView.allowsMagnification = true + + // when: magnifying the content 2× + scrollView.magnification = 2 + + // then: the visible area covers half as much content along each axis + expect(scrollView.visibleSize) == CGSize(width: 50, height: 100) + } + #endif + + #if canImport(UIKit) + func test_visibleSize_isBoundsSize() { + // given: a 100 × 200 scroll view showing a 300 × 500 content + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + scrollView.contentSize = CGSize(width: 300, height: 500) + + // when: insetting the content + scrollView.contentInset = UIEdgeInsets(top: 10, left: 20, bottom: 30, right: 40) + + // then: the visible size is still the bounds size + expect(scrollView.visibleSize) == CGSize(width: 100, height: 200) + + // when: zooming the content 2× + let zoomingView = UIView(frame: CGRect(x: 0, y: 0, width: 300, height: 500)) + scrollView.addSubview(zoomingView) + let delegate = ZoomingDelegate(zoomingView: zoomingView) + withExtendedLifetime(delegate) { + scrollView.delegate = delegate + scrollView.maximumZoomScale = 4 + scrollView.zoomScale = 2 + + // then: the zoom takes effect, and the visible size is still the bounds size + expect(scrollView.zoomScale) == 2 + expect(scrollView.visibleSize) == CGSize(width: 100, height: 200) + } + } + #endif + + // MARK: - Content Container View + + func test_contentContainerView() { + // given: a scroll view + let scrollView = ScrollView() + + // then: the content container is the document view on AppKit, and the scroll view itself on UIKit + #if canImport(AppKit) + expect(scrollView.documentView) === scrollView.contentContainerView + #endif + #if canImport(UIKit) + expect(scrollView.contentContainerView) === scrollView + #endif + } + + // MARK: - Scroll Enabled + + #if canImport(AppKit) + func test_isScrollEnabled_scrollWheel() throws { + // given: a scroll view showing content taller than it, inside a view that records the scroll wheel events it gets + let window = TestWindow() + let container = ScrollWheelRecordingView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + window.contentView().addSubview(container) + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + scrollView.contentSize = CGSize(width: 100, height: 500) + container.addSubview(scrollView) + let cgEvent = try unwrap(CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 1, wheel1: -30, wheel2: 0, wheel3: 0)) + let event = try unwrap(NSEvent(cgEvent: cgEvent)) + + // when: scrolling is disabled and the scroll view gets a scroll wheel event + scrollView.isScrollEnabled = false + scrollView.scrollWheel(with: event) + + // then: the event passes to the next responder, and the scroll view doesn't scroll + expect(container.scrollWheelEventCount) == 1 + expect(scrollView.contentOffset) == .zero + + // when: scrolling is enabled and the scroll view gets the event again + scrollView.isScrollEnabled = true + scrollView.scrollWheel(with: event) + + // then: the scroll view handles the event instead of passing it on + expect(container.scrollWheelEventCount) == 1 + } + #endif + + // MARK: - Scroll Elasticity + + #if canImport(AppKit) func test_scrollElasticity() { // given: a 100 × 100 scroll view whose document is half a point larger than it in both axes let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) @@ -82,5 +311,38 @@ class ScrollViewTests: XCTestCase { expect(scrollView.horizontalScrollElasticity) == .none expect(scrollView.verticalScrollElasticity) == .none } + #endif + + // MARK: - Helpers + + /// The insets as `[top, left, bottom, right]`, since `NSEdgeInsets` isn't `Equatable`. + private static func components(of insets: EdgeInsets) -> [CGFloat] { + [insets.top, insets.left, insets.bottom, insets.right] + } +} + +#if canImport(AppKit) +private final class ScrollWheelRecordingView: NSView { + + private(set) var scrollWheelEventCount = 0 + + override func scrollWheel(with event: NSEvent) { + scrollWheelEventCount += 1 + } +} +#endif + +#if canImport(UIKit) +private final class ZoomingDelegate: NSObject, UIScrollViewDelegate { + + private let zoomingView: UIView + + init(zoomingView: UIView) { + self.zoomingView = zoomingView + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + zoomingView + } } #endif diff --git a/ComposeUI/Tests/ComposeUITests/Performance/ModifierPerformanceTests.swift b/ComposeUI/Tests/ComposeUITests/Performance/ModifierPerformanceTests.swift index 0aca311f..7e327150 100644 --- a/ComposeUI/Tests/ComposeUITests/Performance/ModifierPerformanceTests.swift +++ b/ComposeUI/Tests/ComposeUITests/Performance/ModifierPerformanceTests.swift @@ -234,7 +234,7 @@ class ModifierPerformanceTests: XCTestCase { var offset: CGFloat = 0 let scroll = { offset += Constants.scrollStep - view.setContentOffset(CGPoint(x: 0, y: offset)) // on AppKit, this triggers the render synchronously + view.contentOffset = CGPoint(x: 0, y: offset) // on AppKit, this triggers the render synchronously view.layoutIfNeeded() // on UIKit, this triggers the render } let result = measure(warmup: Constants.scrollWarmup, iterations: Constants.scrollIterations) { _ in diff --git a/ComposeUI/Tests/ComposeUITests/Performance/RenderPerformanceTests.swift b/ComposeUI/Tests/ComposeUITests/Performance/RenderPerformanceTests.swift index 7fe37816..ba4e971e 100644 --- a/ComposeUI/Tests/ComposeUITests/Performance/RenderPerformanceTests.swift +++ b/ComposeUI/Tests/ComposeUITests/Performance/RenderPerformanceTests.swift @@ -214,7 +214,7 @@ class RenderPerformanceTests: XCTestCase { var offset: CGFloat = 0 for _ in 0 ..< Constants.scrollSteps { offset += Constants.scrollStep - view.setContentOffset(CGPoint(x: 0, y: offset)) + view.contentOffset = CGPoint(x: 0, y: offset) view.layoutIfNeeded() } @@ -310,14 +310,14 @@ class RenderPerformanceTests: XCTestCase { // when: scrolling continuously for 30 seconds so a sampling profiler can attach var offset: CGFloat = 0 - let maxOffset = view.contentSize().height - Constants.viewSize.height - 1000 + let maxOffset = view.contentSize.height - Constants.viewSize.height - 1000 let deadline = Date().addingTimeInterval(30) while Date() < deadline { offset += Constants.scrollStep if offset > maxOffset { offset = 0 } - view.setContentOffset(CGPoint(x: 0, y: offset)) + view.contentOffset = CGPoint(x: 0, y: offset) view.layoutIfNeeded() } } @@ -353,14 +353,14 @@ class RenderPerformanceTests: XCTestCase { // when: scrolling continuously for 30 seconds so a sampling profiler can attach var offset: CGFloat = 0 - let maxOffset = view.contentSize().height - Constants.viewSize.height - 1000 + let maxOffset = view.contentSize.height - Constants.viewSize.height - 1000 let deadline = Date().addingTimeInterval(30) while Date() < deadline { offset += Constants.scrollStep if offset > maxOffset { offset = 0 } - view.setContentOffset(CGPoint(x: 0, y: offset)) + view.contentOffset = CGPoint(x: 0, y: offset) view.layoutIfNeeded() } } @@ -395,14 +395,14 @@ class RenderPerformanceTests: XCTestCase { // when: scrolling continuously for 30 seconds so a sampling profiler can attach var offset: CGFloat = 0 - let maxOffset = view.contentSize().height - Constants.viewSize.height - 1000 + let maxOffset = view.contentSize.height - Constants.viewSize.height - 1000 let deadline = Date().addingTimeInterval(30) while Date() < deadline { offset += Constants.scrollStep if offset > maxOffset { offset = 0 } - view.setContentOffset(CGPoint(x: 0, y: offset)) + view.contentOffset = CGPoint(x: 0, y: offset) view.layoutIfNeeded() } } @@ -537,7 +537,7 @@ class RenderPerformanceTests: XCTestCase { var offset: CGFloat = scrollUp ? maxOffset : 0 let result = measure(warmup: 20, iterations: 120) { _ in offset += scrollUp ? -Constants.scrollStep : Constants.scrollStep - view.setContentOffset(CGPoint(x: 0, y: offset)) // on AppKit, this triggers the render synchronously + view.contentOffset = CGPoint(x: 0, y: offset) // on AppKit, this triggers the render synchronously view.layoutIfNeeded() // on UIKit, this triggers the render } @@ -554,7 +554,7 @@ class RenderPerformanceTests: XCTestCase { } } offset += Constants.scrollStep - view.setContentOffset(CGPoint(x: 0, y: offset)) + view.contentOffset = CGPoint(x: 0, y: offset) view.layoutIfNeeded() #endif From bd950855993bf4f9f35614ba2b82105e97cb9f88 Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Wed, 30 Sep 2026 21:07:07 -0700 Subject: [PATCH 2/2] [docs] keep setBounds(_:)'s behavior in the CHANGELOG migration note On UIKit, `setBounds(_:)` assigned `bounds`, which resizes the view around its center, while setting `frame.size` keeps its origin, so the note's replacement changed where a resized view sits. It now says to assign `bounds` on iOS, tvOS and visionOS, to set `frame.size`, then `contentOffset` on macOS, and that setting `frame.size`, then `contentOffset` resizes from the view's origin on every platform. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd957bf6..41f8e6e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - Zero-duration transitions now call their completion, and a completion is also called when its animation is torn down early. - `RenderableTransition.opacity`'s `to` is now optional, and without it the fade ends at the opacity the content sets, for example with the `opacity` modifier, instead of 1. - `ComposeView.tile()` is now final on macOS. The content lays out for the view's bounds inside the border whether or not scroll bars show, so tiling that takes space from the clip view would cover the content. Place accessory views outside the `ComposeView` instead. -- Removed `ScrollViewType`. `ScrollView` now has UIKit's scroll view API on macOS too: `contentOffset` replaces `contentOffset()` and `setContentOffset(_:)`, `contentSize` replaces `contentSize()` and `setContentSize(_:)`, `adjustedContentInset` replaces `contentInsets()`, `contentInset` replaces `setContentInsets(_:)`, and `contentOffset` with `visibleSize` replace `bounds()`. Replace `setBounds(_:)` by setting `frame.size`, then `contentOffset`. `contentView()` and the macOS `documentView()` are no longer public: on macOS, `contentView()` shared its name with AppKit's `NSScrollView.contentView()`, so a call in an optional or inferred context returned the clip view instead of the document view. Use `documentView` on macOS, and the scroll view itself on iOS, tvOS and visionOS. +- Removed `ScrollViewType`. `ScrollView` now has UIKit's scroll view API on macOS too: `contentOffset` replaces `contentOffset()` and `setContentOffset(_:)`, `contentSize` replaces `contentSize()` and `setContentSize(_:)`, `adjustedContentInset` replaces `contentInsets()`, `contentInset` replaces `setContentInsets(_:)`, and `contentOffset` with `visibleSize` replace `bounds()`. To keep `setBounds(_:)`'s behavior, assign `bounds` on iOS, tvOS and visionOS, which resizes the view around its center, and on macOS set `frame.size`, then `contentOffset`, as it did there except for ignoring the x offset. Setting `frame.size`, then `contentOffset` resizes from the view's origin on every platform. `contentView()` and the macOS `documentView()` are no longer public: on macOS, `contentView()` shared its name with AppKit's `NSScrollView.contentView()`, so a call in an optional or inferred context returned the clip view instead of the document view. Use `documentView` on macOS, and the scroll view itself on iOS, tvOS and visionOS. - Renamed `BaseScrollView.isScrollable` to UIKit's `isScrollEnabled`, which `ScrollView` now has on macOS too, and the debug event `renderDidUpdateScrollableBehavior(isScrollable:alwaysBounceHorizontal:alwaysBounceVertical:)` to `renderDidUpdateScrollableBehavior(isScrollEnabled:alwaysBounceHorizontal:alwaysBounceVertical:)`. ### Changes