diff --git a/AGENTS.md b/AGENTS.md index dd9034c..1581ace 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,5 +176,6 @@ Hard-won rules from past corrections, grouped by theme. - Question the premise before designing around it: when an existing behavior drives a decision, first check whether that behavior is intentional (documented, tested for its own sake, or explained in history) or incidental. Incidental behavior is a candidate to change, not a constraint to satisfy. - Surface rejected alternatives: when you consider an option and drop it for scope, cost, or risk, state it in one line with the reason. A silently dropped alternative takes the decision away from the reviewer. - Treat API surface as a variable, not a constraint: when a fix adds compensating logic (invalidation, special cases, extra cache keys) only so that a public type or option stays safe to misuse, first find who can reach that misuse. If only callers outside the module can, propose removing or narrowing the API next to the compensating fix, with the cost of each, before implementing either. Compatibility kept by default is the option that hides its cost. +- Refuse an invalid use where it enters, instead of correcting for it downstream: when tolerating a use that no caller needs, such as a callback changing the settings an operation is applying, takes retries, guards or extra state, make the entry point reject it, with an assertion that keeps the current value and a documented restriction. Each correction tends to expose the next case, while the restriction removes the whole class. - Enumerate a system's inputs before replacing its evaluation: when computed logic replaces what a system evaluates, list every kind of input the system honors, decide for each whether the logic models it or falls back, and test each decision. An input the logic silently misreads gives a wrong result where the replaced path gave a right one, and nothing flags it, such as a Core Animation group that a computed presentation value doesn't see. - Check whether an input applies before requiring it to be supported: an input that has no effect, such as an animation that hasn't begun, shouldn't force a fallback or a failure only because the logic can't evaluate it. diff --git a/CHANGELOG.md b/CHANGELOG.md index f26541b..91b549d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,10 @@ - Delayed animations are now scheduled with Core Animation's `beginTime` instead of a GCD timer. - 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 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. +- On macOS, while legacy scroll bars show, `ComposeView` lays out its content for its size minus the space the scroll bars take, so they no longer cover the content. Render handlers and renderable updates receive this smaller area as their render bounds, matching `visibleSize`. `onWillLayout` can now run more than once in a render pass, because the content lays out again when a scroll bar takes space. `tile()` is now final on `ComposeView`, because it sets the visible size that the content lays out for. - 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()`. On macOS, `setContentOffset(_:)` clamped the offset into the scrollable range, while setting `contentOffset` keeps it as set, as on UIKit. 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 and clamping the y offset into the scrollable range. 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:)`. -- On macOS, `ComposeView` no longer supports automatic content insets, magnification or borders, so its content insets stay the same under the title bar and toolbar, and its content always lays out for its bounds. `automaticallyAdjustsContentInsets` and `allowsMagnification` stay `false`, `magnification`, `minMagnification` and `maxMagnification` stay 1, so `setMagnification(_:centeredAt:)` and `magnify(toFit:)` don't magnify either, even through the animator, and `borderType` stays `.noBorder`. Changing them asserts and keeps the current value, and they're final on `ComposeView`. +- On macOS, `ComposeView` no longer supports automatic content insets, magnification, borders or auto-hiding scroll bars, so its content insets stay the same under the title bar and toolbar. `automaticallyAdjustsContentInsets`, `allowsMagnification` and `autohidesScrollers` stay `false`, `magnification`, `minMagnification` and `maxMagnification` stay 1, so `setMagnification(_:centeredAt:)` and `magnify(toFit:)` don't magnify either, even through the animator, and `borderType` stays `.noBorder`. Changing them asserts and keeps the current value, and they're final on `ComposeView`. ### Changes diff --git a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+ContentUpdateContext.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+ContentUpdateContext.swift index 94265eb..2f81850 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+ContentUpdateContext.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView+ContentUpdateContext.swift @@ -57,8 +57,11 @@ extension ComposeView { /// The bounds from the last completed render pass, or nil before the first render. let previousRenderBounds: CGRect? - /// The viewport bounds proposed for this render pass's layout. - let renderBounds: CGRect + /// The view's bounds when the update was made: the content offset as the origin and the view's size. + /// + /// The bounds.size is not always the same as the visible bounds, on macOS, the legacy scroll bar + /// (`NSScrollView.scrollerStyle == .legacy`) takes space from the view's size. + let bounds: CGRect /// The parent's animation decision handed over with prepared content, capping the render pass that applies it. /// `.all` for other updates, which caps nothing. @@ -71,7 +74,7 @@ extension ComposeView { lhs.contentEvaluation === rhs.contentEvaluation && lhs.updateType == rhs.updateType && lhs.previousRenderBounds == rhs.previousRenderBounds && - lhs.renderBounds == rhs.renderBounds && + lhs.bounds == rhs.bounds && lhs.preparedAnimationDecision == rhs.preparedAnimationDecision } diff --git a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift index 46da2e1..a2153e2 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift @@ -150,6 +150,9 @@ open class ComposeView: BaseScrollView { /// The bounds from the last completed render pass, or nil before the first render. private var lastRenderBounds: CGRect? + /// The view's size from the last completed render pass, or nil before the first render. + private var lastBoundsSize: CGSize? + /// The ids of the renderable items that are being rendered. private var renderableItemIds: [ComposeNodeId] = [] @@ -380,9 +383,17 @@ open class ComposeView: BaseScrollView { /// /// This handler runs before the content size is updated. /// + /// It runs before each layout of a render pass. Usually, the layout is performed once, but on macOS, with the legacy + /// scroll bar style (`NSScrollView.scrollerStyle == .legacy`), if the content is larger than the view's bounds, the + /// layout is performed up to three times: for the full view size, for the view size minus the scroll bar width, and + /// for the view size minus both scroll bars if the content then overflows along the other axis. + /// /// This handler runs inside the render pass, so a refresh or layout it requests for this view, or for a view /// containing it, waits until the pass ends (see `refresh(animated:)`). /// + /// The handler can adjust things like the content offset, but it can't change the view's size, its visible size or its + /// scroll settings, such as `scrollBehavior`, `scrollIndicatorBehavior` or `clippingBehavior`, etc. + /// /// Calling this replaces any previously set handler. /// /// - Parameter handler: The will-layout handler. @@ -482,7 +493,10 @@ open class ComposeView: BaseScrollView { /// The view's scrollable behavior. public enum ScrollBehavior { - /// The view is scrollable if the content is larger than the view's bounds. Otherwise, the view is not scrollable. + /// The view is scrollable if the content is larger than the visible size. Otherwise, the view is not scrollable. + /// + /// On macOS, with the legacy scroll bar style (`NSScrollView.scrollerStyle == .legacy`), the scroll bars take space + /// from the visible size. case auto /// The view does not modify scroll settings. `isScrollEnabled` and `alwaysBounceHorizontal`/`alwaysBounceVertical` are managed by you. @@ -501,7 +515,10 @@ open class ComposeView: BaseScrollView { /// The view's scroll indicator behavior. public enum ScrollIndicatorBehavior { - /// The scroll indicators are shown if the content is larger than the view's bounds. Otherwise, the scroll indicators are hidden. + /// A scroll indicator is shown for each axis where the content is larger than the view's bounds, and hidden otherwise. + /// + /// On macOS, with the legacy scroll bar style (`NSScrollView.scrollerStyle == .legacy`), a scroll bar takes space, + /// so the other axis's scroll indicator also shows if the content overflows the space that remains. case auto /// The view does not modify scroll indicator settings. `showsHorizontalScrollIndicator` and `showsVerticalScrollIndicator` are managed by you. @@ -517,6 +534,9 @@ open class ComposeView: BaseScrollView { /// The view's scroll indicator behavior. The default value is `.auto`. Requires a refresh to take effect. public var scrollIndicatorBehavior: ScrollIndicatorBehavior = .auto + /// The scroll indicator behavior the scroll indicators were last updated for, or nil before the first render. + private var lastScrollIndicatorBehavior: ScrollIndicatorBehavior? + // MARK: - Clipping /// The view's clipping behavior. @@ -718,12 +738,16 @@ open class ComposeView: BaseScrollView { // MARK: - Tiling - /// Tiles the clip view and the scrollers. - /// - /// It's final because the content lays out for the view's bounds, so space that custom tiling takes from the clip view - /// would cover the content. - override public final func tile() { // swiftlint:disable:this unneeded_override + /// Final, because this method sets the visible size that the content lays out for. + override public final func tile() { super.tile() + + // AppKit re-tiles without laying the view out when the scroller style changes or a scroll bar shows or hides, so ask + // for a layout when that changes the visible size the content rendered for. a render pass renders for the visible + // size its own re-tiles leave, and AppKit ignores the request during a layout, which renders next anyway. + if !isRendering, boundsChangedSinceLastRender() { + needsLayout = true + } } // MARK: - Locked Scroll View Settings @@ -813,6 +837,21 @@ open class ComposeView: BaseScrollView { } } + /// Always `false`, since `ComposeView` shows and hides its scroll bars itself (see `scrollIndicatorBehavior`). + /// + /// Setting it to `true` asserts and keeps it `false`. + override public final var autohidesScrollers: Bool { + get { + super.autohidesScrollers + } + set { + // a legacy scroll bar that AppKit hides on its own changes the size the content lays out for, which can change + // whether AppKit hides it, so the two would alternate + ComposeUI.assert(!newValue, "ComposeView doesn't support auto-hiding scrollers") + super.autohidesScrollers = false + } + } + #endif // MARK: - Render @@ -849,7 +888,7 @@ open class ComposeView: BaseScrollView { contentEvaluation: contentEvaluation, updateType: .refresh(isAnimated: animated), previousRenderBounds: lastRenderBounds, - renderBounds: renderBounds(), + bounds: CGRect(origin: contentOffset, size: bounds.size), preparedAnimationDecision: preparedContent?.animationDecision ?? .all ) @@ -935,10 +974,7 @@ open class ComposeView: BaseScrollView { return } - let renderBounds = renderBounds() - // `lastRenderBounds` is nil before the first render. compare against `.zero` in that case, so that the view does not - // render while its size is still zero, and renders once it gets a non-zero size. - if contentUpdateContext == nil, renderBounds != (lastRenderBounds ?? .zero) { + if contentUpdateContext == nil, boundsChangedSinceLastRender() { // no pending render request but bounds changed, should re-render the content let contentNode = contentNode ?? LayoutCacheNode(node: _makeContent()) @@ -951,7 +987,7 @@ open class ComposeView: BaseScrollView { contentEvaluation: contentEvaluation, updateType: .boundsChange, previousRenderBounds: lastRenderBounds, - renderBounds: renderBounds, + bounds: CGRect(origin: contentOffset, size: bounds.size), preparedAnimationDecision: .all ) } @@ -1023,7 +1059,7 @@ open class ComposeView: BaseScrollView { // a render handler or a renderable lifecycle block can change the bounds after the pass read them, so render the // current bounds after the pass if they differ - if renderBounds() != lastRenderBounds { + if boundsChangedSinceLastRender() { onNextRunLoop { [weak self] in self?.renderBoundsChangeIfNeeded() } @@ -1039,39 +1075,29 @@ open class ComposeView: BaseScrollView { let contentNode = context.contentNode let contentEvaluation = context.contentEvaluation - var bounds = context.renderBounds - let boundsSize = bounds.size - #if DEBUG debug?.onEvent(.renderWillBegin(contentNode: contentNode)) #endif - willLayoutHandler?(self, WillLayoutContext(containerSize: boundsSize, renderType: context.renderType(bounds: bounds))) - - #if DEBUG - let layoutVisibleBounds = bounds.inset(by: visibleBoundsInsets) - debug?.onEvent(.renderWillLayout(contentNode: contentNode, bounds: bounds, visibleBounds: layoutVisibleBounds)) - #endif + let oldShowsHorizontalScrollIndicator = showsHorizontalScrollIndicator + let oldShowsVerticalScrollIndicator = showsVerticalScrollIndicator - // do the layout - let layoutContext = ComposeNodeLayoutContext(scaleFactor: contentScaleFactor, contentEvaluation: contentEvaluation) - _ = contentNode.layout(containerSize: boundsSize, context: layoutContext) + // perform the layout + let layoutResult = layout(context) + let renderSize = layoutResult.renderSize + var renderBounds = CGRect(origin: context.bounds.origin, size: renderSize) var contentSize = contentNode.size - #if DEBUG - debug?.onEvent(.renderDidLayout(contentSize: contentSize)) - #endif - - // the layout and the render bounds compute their sizes with different arithmetic, so content that fits the bounds - // exactly can differ from them by floating-point noise. compare them with `extends(beyond:)` instead of `<` and `>`, - // so the noise doesn't center, scroll, clip, or show scroll indicators. + // the layout and the render bounds compute their sizes with different arithmetic, so content that fits the render + // bounds exactly can differ from them by floating-point noise. compare them with `extends(beyond:)` instead of `<` + // and `>`, so the noise doesn't center, scroll, clip, or show scroll indicators. var centeredChildFrame: CGRect? - if boundsSize.width.extends(beyond: contentSize.width) || boundsSize.height.extends(beyond: contentSize.height) { - // if content is smaller than the bounds in either dimension, should center the content + if renderSize.width.extends(beyond: contentSize.width) || renderSize.height.extends(beyond: contentSize.height) { + // if content is smaller than the render bounds in either dimension, should center the content let adjustedContentSize = CGSize( - width: max(contentSize.width, boundsSize.width), - height: max(contentSize.height, boundsSize.height) + width: max(contentSize.width, renderSize.width), + height: max(contentSize.height, renderSize.height) ) // logic copied from FrameNode.renderableItems(in:) (part 1) @@ -1079,22 +1105,36 @@ open class ComposeView: BaseScrollView { contentSize = adjustedContentSize } - let overflowsHorizontally = contentSize.width.extends(beyond: boundsSize.width) - let overflowsVertically = contentSize.height.extends(beyond: boundsSize.height) + let overflowsHorizontally = contentSize.width.extends(beyond: renderSize.width) + let overflowsVertically = contentSize.height.extends(beyond: renderSize.height) - // round the content up to whole pixels, except along an axis it fits: there it can still exceed the bounds by - // floating-point noise, which rounding up would turn into a pixel to scroll, so use the bounds instead. + // round the content up to whole pixels, except along an axis it fits: there it can still exceed the render size by + // floating-point noise, which rounding up would turn into a pixel to scroll, so use the render size instead. var roundedContentSize = contentSize.roundedUp(scaleFactor: contentScaleFactor) if !overflowsHorizontally { - roundedContentSize.width = boundsSize.width + roundedContentSize.width = renderSize.width } if !overflowsVertically { - roundedContentSize.height = boundsSize.height + roundedContentSize.height = renderSize.height } + #if canImport(AppKit) + if self.contentSize != roundedContentSize || renderSize != context.previousRenderBounds?.size { + invalidateScrollElasticity() + } + #endif + // set content size self.contentSize = roundedContentSize + #if canImport(AppKit) + if layoutResult.needsContentOffsetClamp { + // the layout kept the offset across its scroll bar changes. clamp it now, against the final content size and + // visible area, as AppKit clamps it. + contentOffset = contentView.constrainBoundsRect(CGRect(origin: contentOffset, size: contentView.bounds.size)).origin + } + #endif + // update scrollable behavior switch scrollBehavior { case .auto: @@ -1132,24 +1172,7 @@ open class ComposeView: BaseScrollView { debug?.onEvent(.renderDidUpdateClippingBehavior(clipsToBounds: clipsToBounds)) #endif - // update scroll indicator behavior - let oldShowsHorizontalScrollIndicator = showsHorizontalScrollIndicator - let oldShowsVerticalScrollIndicator = showsVerticalScrollIndicator - - switch scrollIndicatorBehavior { - case .auto: - showsHorizontalScrollIndicator = overflowsHorizontally - showsVerticalScrollIndicator = overflowsVertically - case .manual: - break - case .always: - showsHorizontalScrollIndicator = true - showsVerticalScrollIndicator = true - case .never: - showsHorizontalScrollIndicator = false - showsVerticalScrollIndicator = false - } - + // flash a scroll indicator the layout showed, now that the content size is set, so the flash shows the new range if (oldShowsHorizontalScrollIndicator == false && showsHorizontalScrollIndicator == true) || (oldShowsVerticalScrollIndicator == false && showsVerticalScrollIndicator == true) { @@ -1160,37 +1183,34 @@ open class ComposeView: BaseScrollView { debug?.onEvent(.renderDidUpdateScrollIndicatorBehavior(showsHorizontalScrollIndicator: showsHorizontalScrollIndicator, showsVerticalScrollIndicator: showsVerticalScrollIndicator)) #endif - #if canImport(AppKit) - invalidateScrollElasticity() - #endif + // adjust content offset // 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 + renderBounds.origin = contentOffset if let willRenderHandler { - willRenderHandler(self, WillRenderContext(contentSize: roundedContentSize, renderBounds: bounds, renderType: context.renderType(bounds: bounds))) + willRenderHandler(self, WillRenderContext(contentSize: roundedContentSize, renderBounds: renderBounds, renderType: context.renderType(bounds: renderBounds))) - // the will-render handler may change the bounds, so read them again. - // only the origin is picked to ensure the correct content offset is used for rendering. + // the will-render handler may change the bounds, so read the content offset again. // ignoring the size change because the layout above already used the old size. if the size changes, the render // pass will be triggered again after the pass ends (see `render()`). - bounds.origin = renderBounds().origin + renderBounds.origin = contentOffset } - // the bounds are final from here on, so the render type and the animation decision are made once for the render pass. + // the render bounds are final from here on, so the render type and the animation decision are made once per pass. // a pass within a parent's render pass is capped by the parent's animation decision: the view's animation behavior // can lower it but never raise it. the view's own updates carry `.all`, which caps nothing. nested views rendering // within this pass inherit the animation decision in turn, so a cap reaches every depth. - let renderType = context.renderType(bounds: bounds) + let renderType = context.renderType(bounds: renderBounds) let animationDecision = animationBehavior .animationDecision(renderType: renderType, contentView: self) .capped(by: animationDecisionCap) renderingAnimationDecision = animationDecision - let visibleBounds = bounds.inset(by: visibleBoundsInsets) + let visibleBounds = renderBounds.inset(by: visibleBoundsInsets) // get renderable items let renderableItems: [RenderableItem] @@ -1420,7 +1440,7 @@ open class ComposeView: BaseScrollView { oldFrame: oldFrame, newFrame: newFrame, previousRenderBounds: context.previousRenderBounds, - renderBounds: bounds, + renderBounds: renderBounds, animationTiming: animationTiming, contentView: self, contentEvaluation: contentEvaluation, @@ -1504,7 +1524,7 @@ open class ComposeView: BaseScrollView { oldFrame: frameAfterWillInsert, newFrame: newFrame, previousRenderBounds: context.previousRenderBounds, - renderBounds: bounds, + renderBounds: renderBounds, animationTiming: nil, // no animation for insertion contentView: self, contentEvaluation: contentEvaluation, @@ -1594,14 +1614,190 @@ open class ComposeView: BaseScrollView { #endif if let didRenderHandler { - didRenderHandler(self, DidRenderContext(contentSize: contentSize, renderBounds: bounds, renderType: renderType)) + didRenderHandler(self, DidRenderContext(contentSize: contentSize, renderBounds: renderBounds, renderType: renderType)) } #if DEBUG debug?.onEvent(.renderDidFinish(renderableItemIds: renderableItemIds, renderableItemMap: renderableItemMap, renderableMap: renderableMap)) #endif - lastRenderBounds = bounds + lastRenderBounds = renderBounds + lastBoundsSize = context.bounds.size + } + + /// Performs the layout for a content update, which also updates the scroll indicators. + /// + /// - Parameter context: The content update being rendered. + /// - Returns: The render size the content laid out for, and whether the offset needs a clamp once the content size is + /// set, since the layout changed the scroll bars and undid the clamp AppKit applied for them. + private func layout(_ context: ContentUpdateContext) -> (renderSize: CGSize, needsContentOffsetClamp: Bool) { + let contentNode = context.contentNode + let boundsSize = context.bounds.size + + // hiding a legacy scroll bar grows the visible area, and AppKit clamps the offset to it, against the old content size + // and even when the update shows the scroll bar again. keep the offset instead, and clamp it once the final content + // size is set (see `render(_:)`). + let oldScrollIndicators = (showsHorizontalScrollIndicator, showsVerticalScrollIndicator) + var didKeepContentOffset = false + func keepingContentOffset(_ change: () -> Void) { + let contentOffset = self.contentOffset + change() + if self.contentOffset != contentOffset { + self.contentOffset = contentOffset + didKeepContentOffset = true + } + } + func setScrollIndicators(horizontal: Bool, vertical: Bool) { + keepingContentOffset { + showsHorizontalScrollIndicator = horizontal + showsVerticalScrollIndicator = vertical + } + } + + var laidOutSize: CGSize? + func layout(for containerSize: CGSize) { + guard containerSize != laidOutSize else { + return + } + + // report the current offset, not the one the update was made with, since a will-layout handler can move it between + // the layouts of a pass, and a held update can run after the view scrolled. + let bounds = CGRect(origin: contentOffset, size: containerSize) + if let willLayoutHandler { + let scrollSettings = (scrollBehavior, scrollIndicatorBehavior, clippingBehavior) + let scrollIndicators = (horizontal: showsHorizontalScrollIndicator, vertical: showsVerticalScrollIndicator) + #if canImport(AppKit) + let scrollerStyle = self.scrollerStyle + #endif + let renderSize = self.renderSize(for: boundsSize) + + willLayoutHandler(self, WillLayoutContext(containerSize: containerSize, renderType: context.renderType(bounds: bounds))) + + // the handler can't change the scroll settings or the visible size + if (scrollBehavior, scrollIndicatorBehavior, clippingBehavior) != scrollSettings { + ComposeUI.assertFailure("onWillLayout can't change scrollBehavior, scrollIndicatorBehavior or clippingBehavior") + (scrollBehavior, scrollIndicatorBehavior, clippingBehavior) = scrollSettings + } + if self.renderSize(for: boundsSize) != renderSize { + ComposeUI.assertFailure("onWillLayout can't change the visible size") + keepingContentOffset { + #if canImport(AppKit) + self.scrollerStyle = scrollerStyle + #endif + showsHorizontalScrollIndicator = scrollIndicators.horizontal + showsVerticalScrollIndicator = scrollIndicators.vertical + } + } + } + + #if DEBUG + debug?.onEvent(.renderWillLayout(contentNode: contentNode, bounds: bounds, visibleBounds: bounds.inset(by: visibleBoundsInsets))) + #endif + + // made after the handler, which can change the scale + let layoutContext = ComposeNodeLayoutContext(scaleFactor: contentScaleFactor, contentEvaluation: context.contentEvaluation) + _ = contentNode.layout(containerSize: containerSize, context: layoutContext) + laidOutSize = containerSize + + #if DEBUG + debug?.onEvent(.renderDidLayout(contentSize: contentNode.size)) + #endif + } + + switch scrollIndicatorBehavior { + case .auto: + let keepsScrollIndicators: Bool + switch context.updateType { + case .refresh: + keepsScrollIndicators = false + case .boundsChange: + // a scroll changes neither the content, the view's size nor the render size, so it keeps the scroll indicators, + // if the last update also decided them automatically. the content then lays out once, for the render size the + // cached layout already has. + let isScroll = boundsSize == lastBoundsSize && context.previousRenderBounds?.size == self.renderSize(for: boundsSize) + keepsScrollIndicators = isScroll && lastScrollIndicatorBehavior == .auto + } + + if !keepsScrollIndicators { + // the content or its container size changed, so lay out for the view's full size to check whether the scroll + // indicators need to change. + layout(for: boundsSize) + setScrollIndicators( + horizontal: contentNode.size.width.extends(beyond: boundsSize.width), + vertical: contentNode.size.height.extends(beyond: boundsSize.height) + ) + + // after updating the scroll indicators, the render size leaves out the space the legacy scroll bars take. + // if it's smaller, lay out again for it, and show a scroll indicator for any axis the content now overflows. + let renderSize = self.renderSize(for: boundsSize) + if renderSize != boundsSize { + layout(for: renderSize) + setScrollIndicators( + horizontal: showsHorizontalScrollIndicator || contentNode.size.width.extends(beyond: renderSize.width), + vertical: showsVerticalScrollIndicator || contentNode.size.height.extends(beyond: renderSize.height) + ) + } + } + case .manual: + break + case .always: + setScrollIndicators(horizontal: true, vertical: true) + case .never: + setScrollIndicators(horizontal: false, vertical: false) + } + lastScrollIndicatorBehavior = scrollIndicatorBehavior + + let renderSize = self.renderSize(for: boundsSize) + layout(for: renderSize) + + // if the scroll bars end as they started, the visible area hasn't changed, so the offset needs no clamp, and AppKit + // clamps it itself for a content size change. clamping anyway would move an offset set outside the scrollable range, + // which the view keeps otherwise. + let needsContentOffsetClamp = didKeepContentOffset && (showsHorizontalScrollIndicator, showsVerticalScrollIndicator) != oldScrollIndicators + return (renderSize, needsContentOffsetClamp) + } + + /// Returns the size the content lays out and renders for: the bounds size less the space that legacy scroll bars take + /// on macOS. + /// + /// - Parameter boundsSize: The bounds size the render pass was made for. + /// - Returns: The render size. + private func renderSize(for boundsSize: CGSize) -> CGSize { + let currentBoundsSize = bounds.size + let visibleSize = self.visibleSize + + if boundsSize == currentBoundsSize { + // the view hasn't resized since the pass was made, so the visible size is the render size. + // use it as is rather than the math below, so the render size always matches `renderBounds()`. + return visibleSize + } else { + // the view resized after the pass was made: render for the earlier size, minus the space the scroll bars take now. + let scrollBarSpace = currentBoundsSize - visibleSize + let renderSize = boundsSize - scrollBarSpace + return CGSize(width: max(renderSize.width, 0), height: max(renderSize.height, 0)) + } + } + + /// Returns the bounds used for layout and rendering. + private func renderBounds() -> CGRect { + #if canImport(AppKit) + // legacy scroll bar shrinks the visible size, so visibleSize could be smaller than the bounds.size + return CGRect(origin: contentOffset, size: visibleSize) + #endif + + #if canImport(UIKit) + return bounds + #endif + } + + /// Returns whether the render bounds or the view's size changed since the last render pass. + /// + /// Before the first render, they count as changed once the view has a size, so the view doesn't render while its size + /// is still zero. + private func boundsChangedSinceLastRender() -> Bool { + // compare the view's size too: with legacy scroll bars on macOS, a resize can leave the render bounds the same while + // the scroll bars it needs change + renderBounds() != (lastRenderBounds ?? .zero) || bounds.size != (lastBoundsSize ?? .zero) } /// Updates the renderable layer's `zPosition` so that renderables render in the items order, regardless of the @@ -1639,20 +1835,6 @@ 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 (`visibleSize`), so the size comes from the view's bounds, where AppKit - // tiles the clip view without scrollers. it then depends only on the bounds, so showing or hiding a scroller never - // changes the layout. - return CGRect(origin: contentOffset, size: self.bounds.size) - #endif - - #if canImport(UIKit) - return bounds - #endif - } - // MARK: - Constants private enum Constants { diff --git a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+ParentResizeTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+ParentResizeTests.swift index 6b65bcc..f07f87a 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+ParentResizeTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeNodes/ComposeViewNode+ParentResizeTests.swift @@ -843,8 +843,8 @@ class ComposeViewNode_ParentResizeTests: XCTestCase { #if canImport(AppKit) func test_parentResize_capsEveryNestedLayoutTheResizeTriggers_withLegacyScrollers() throws { - // given: a parent without animations and a scrolled nested view with legacy scrollers, whose dynamic behavior always - // animates. growing the nested view also clamps its scroll offset near the end. + // given: a parent without animations and a scrolled nested view with legacy scrollers, which its row lays out beside, + // and whose dynamic behavior always animates. growing the nested view also clamps its scroll offset near the end. let timing = AnimationTiming.linear(duration: 10) var childView: ComposeView? var childRenderTypes: [ComposeView.RenderType] = [] @@ -882,8 +882,9 @@ class ComposeViewNode_ParentResizeTests: XCTestCase { let row = try unwrap(layer) child.contentOffset = CGPoint(x: 0, y: 290) child.layoutIfNeeded() + let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) expect(child.contentOffset) == CGPoint(x: 0, y: 290) - expect(row.frame) == CGRect(x: 0, y: 0, width: 100, height: 400) + expect(row.frame) == CGRect(x: 0, y: 0, width: 100 - thickness, height: 400) row.removeAllAnimations() childRenderTypes.removeAll() layerContexts.removeAll() @@ -896,14 +897,14 @@ class ComposeViewNode_ParentResizeTests: XCTestCase { // then: the resize clamps the offset to the end of the content and lays the nested view out once, for its new size at // that offset, and the layout is capped by the parent's decision, so the row snaps 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.frame) == CGRect(x: 0, y: 0, width: 140 - thickness, height: 400) expect(row.animationKeys()) == nil 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)) + previousBounds: CGRect(x: 0, y: 290, width: 100 - thickness, height: 100 - thickness), + bounds: CGRect(origin: child.contentOffset, size: CGSize(width: 140 - thickness, height: 140 - thickness)) ), ] expect(childRenderTypes.count) == layerContexts.count diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationBehaviorTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationBehaviorTests.swift index 8b92ee7..47aa2df 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationBehaviorTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+AnimationBehaviorTests.swift @@ -740,17 +740,19 @@ class ComposeView_AnimationBehaviorTests: XCTestCase { return false } + let visibleSize = view.visibleSize view.contentOffset = CGPoint(x: 0, y: 20) view.layoutIfNeeded() - // then: the render type should have correct previous bounds - expect(calledRenderType) == .boundsChange(previousBounds: CGRect(x: 0, y: 10, width: 120, height: 80), bounds: CGRect(x: 0, y: 20, width: 120, height: 80)) + // then: the render type should have correct previous bounds, the visible areas, which the scrollers take space from + // on AppKit + expect(calledRenderType) == .boundsChange(previousBounds: CGRect(origin: CGPoint(x: 0, y: 10), size: visibleSize), bounds: CGRect(origin: CGPoint(x: 0, y: 20), size: visibleSize)) // when: resize the view view.frame.size = CGSize(width: 140, height: 90) view.layoutIfNeeded() // then: the render type should have correct previous bounds - expect(calledRenderType) == .boundsChange(previousBounds: CGRect(x: 0, y: 20, width: 120, height: 80), bounds: CGRect(x: 0, y: 20, width: 140, height: 90)) + expect(calledRenderType) == .boundsChange(previousBounds: CGRect(origin: CGPoint(x: 0, y: 20), size: visibleSize), bounds: CGRect(origin: CGPoint(x: 0, y: 20), size: view.visibleSize)) } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ContentUpdateContextTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ContentUpdateContextTests.swift index 1392c04..c919891 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ContentUpdateContextTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ContentUpdateContextTests.swift @@ -39,17 +39,17 @@ class ComposeView_ContentUpdateContextTests: XCTestCase { let node = ComposeView.LayoutCacheNode(node: ColorNode(.red)) let evaluation = ContentEvaluation() let bounds = CGRect(x: 0, y: 0, width: 100, height: 100) - let original = ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, renderBounds: bounds, preparedAnimationDecision: .all) - let same = ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, renderBounds: bounds, preparedAnimationDecision: .all) + let original = ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, bounds: bounds, preparedAnimationDecision: .all) + let same = ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, bounds: bounds, preparedAnimationDecision: .all) let different = [ - ComposeView.ContentUpdateContext(contentNode: ComposeView.LayoutCacheNode(node: ColorNode(.red)), contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, renderBounds: bounds, preparedAnimationDecision: .all), - ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: ContentEvaluation(), updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, renderBounds: bounds, preparedAnimationDecision: .all), - ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: true), previousRenderBounds: .zero, renderBounds: bounds, preparedAnimationDecision: .all), - ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .boundsChange, previousRenderBounds: .zero, renderBounds: bounds, preparedAnimationDecision: .all), - ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, renderBounds: .zero, preparedAnimationDecision: .all), - ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: bounds, renderBounds: bounds, preparedAnimationDecision: .all), - ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: nil, renderBounds: bounds, preparedAnimationDecision: .all), - ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, renderBounds: bounds, preparedAnimationDecision: ComposeView.AnimationDecision.transitionsOnly), + ComposeView.ContentUpdateContext(contentNode: ComposeView.LayoutCacheNode(node: ColorNode(.red)), contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, bounds: bounds, preparedAnimationDecision: .all), + ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: ContentEvaluation(), updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, bounds: bounds, preparedAnimationDecision: .all), + ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: true), previousRenderBounds: .zero, bounds: bounds, preparedAnimationDecision: .all), + ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .boundsChange, previousRenderBounds: .zero, bounds: bounds, preparedAnimationDecision: .all), + ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, bounds: .zero, preparedAnimationDecision: .all), + ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: bounds, bounds: bounds, preparedAnimationDecision: .all), + ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: nil, bounds: bounds, preparedAnimationDecision: .all), + ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, bounds: bounds, preparedAnimationDecision: ComposeView.AnimationDecision.transitionsOnly), ] // then: content identity and every update field participate in equality @@ -142,7 +142,7 @@ class ComposeView_ContentUpdateContextTests: XCTestCase { let originalNode = contentNode let originalEvaluation = contentEvaluation let bounds = CGRect(x: 0, y: 0, width: 100, height: 100) - let pass = ComposeView.ContentUpdateContext(contentNode: contentNode, contentEvaluation: contentEvaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, renderBounds: bounds, preparedAnimationDecision: .all) + let pass = ComposeView.ContentUpdateContext(contentNode: contentNode, contentEvaluation: contentEvaluation, updateType: .refresh(isAnimated: false), previousRenderBounds: .zero, bounds: bounds, preparedAnimationDecision: .all) let originalValue = pass.contentEvaluation.lazyValue(for: provider) // when: a later refresh selects different content while the earlier pass is kept @@ -275,7 +275,7 @@ class ComposeView_ContentUpdateContextTests: XCTestCase { let bounds = CGRect(x: 0, y: 0, width: 100, height: 100) let scrolledBounds = CGRect(x: 0, y: 20, width: 100, height: 100) func makeContext(_ updateType: ComposeView.ContentUpdateContext.ContentUpdateType, previousRenderBounds: CGRect?) -> ComposeView.ContentUpdateContext { - ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: updateType, previousRenderBounds: previousRenderBounds, renderBounds: bounds, preparedAnimationDecision: .all) + ComposeView.ContentUpdateContext(contentNode: node, contentEvaluation: evaluation, updateType: updateType, previousRenderBounds: previousRenderBounds, bounds: bounds, preparedAnimationDecision: .all) } let animatedRefresh = makeContext(.refresh(isAnimated: true), previousRenderBounds: bounds) let refresh = makeContext(.refresh(isAnimated: false), previousRenderBounds: nil) diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+LegacyScrollBarsTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+LegacyScrollBarsTests.swift new file mode 100644 index 0000000..37d2275 --- /dev/null +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+LegacyScrollBarsTests.swift @@ -0,0 +1,867 @@ +// +// ComposeView+LegacyScrollBarsTests.swift +// ComposéUI +// +// Created by Honghao Zhang on 10/1/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 + +import ChouTiTest + +@testable import ComposeUI + +class ComposeView_LegacyScrollBarsTests: XCTestCase { + + private let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + + // MARK: - Layout + + func test_verticalScrollBar_contentLaysOutBesideIt() { + // given: a 120 × 200 view with legacy scroll bars, showing content as wide as the view and 300 pt tall + let state = WidthDependentNode.State() + let view = makeView { WidthDependentNode(state: state, height: { _ in 300 }) } + + // when: the view refreshes + view.refresh(animated: false) + + // then: the vertical scroll bar shows, and the content lays out beside it, so it doesn't scroll sideways + expect(view.hasVerticalScroller) == true + expect(view.hasHorizontalScroller) == false + expect(view.visibleSize) == CGSize(width: 120 - thickness, height: 200) + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120 - thickness, height: 200) + expect(view.contentSize) == CGSize(width: 120 - thickness, height: 300) + expect(view.maxOffsetX) == 0 + } + + func test_horizontalScrollBar_contentLaysOutBesideIt() { + // given: a 120 × 200 view with legacy scroll bars, showing content 300 pt wide and as tall as the view + let view = makeView { LayerNode().frame(width: 300, height: .flexible) } + + // when: the view refreshes + view.refresh(animated: false) + + // then: the horizontal scroll bar shows, and the content lays out above it, so it doesn't scroll vertically + expect(view.hasHorizontalScroller) == true + expect(view.hasVerticalScroller) == false + expect(view.visibleSize) == CGSize(width: 120, height: 200 - thickness) + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120, height: 200 - thickness) + expect(view.contentSize) == CGSize(width: 300, height: 200 - thickness) + expect(view.maxOffsetY) == 0 + } + + func test_bothScrollBars_contentLaysOutBesideThem() { + // given: a 120 × 200 view with legacy scroll bars, showing content 300 pt wide and tall + let view = makeView { LayerNode().frame(width: 300, height: 300) } + + // when: the view refreshes + view.refresh(animated: false) + + // then: both scroll bars show, and the content lays out for the space they leave + expect(view.hasHorizontalScroller) == true + expect(view.hasVerticalScroller) == true + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120 - thickness, height: 200 - thickness) + expect(view.contentSize) == CGSize(width: 300, height: 300) + } + + func test_contentFitsTheBoundsButNotBesideTheVerticalScrollBar_showsTheHorizontalScrollBar() { + // given: a 120 × 200 view with legacy scroll bars, showing content 300 pt tall and 115 pt wide, which fits the view's + // width but not the width beside a vertical scroll bar + var containerSizes: [CGSize] = [] + let view = makeView { LayerNode().frame(width: 115, height: 300) } + view.onWillLayout { _, context in + containerSizes.append(context.containerSize) + } + + // when: the view refreshes + view.refresh(animated: false) + + // then: the vertical scroll bar makes the content overflow sideways, so the horizontal scroll bar shows too, and the + // content lays out once more for the space both leave + expect(view.hasVerticalScroller) == true + expect(view.hasHorizontalScroller) == true + expect(containerSizes) == [ + CGSize(width: 120, height: 200), + CGSize(width: 120 - thickness, height: 200), + CGSize(width: 120 - thickness, height: 200 - thickness), + ] + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120 - thickness, height: 200 - thickness) + } + + func test_contentOverflowsTheBoundsButFitsBesideTheScrollBar_keepsTheScrollBar() { + // given: a 120 × 200 view with legacy scroll bars, showing content as wide as the view and 1.75 times as tall, which + // overflows the view's height but fits beside a vertical scroll bar + let state = WidthDependentNode.State() + var renderCount = 0 + let view = makeView { WidthDependentNode(state: state, height: { $0 * 1.75 }) } + view.onDidRender { _, _ in + renderCount += 1 + } + + // when: the view refreshes, and the run loop turns, where a follow-up render pass would run + view.refresh(animated: false) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + + // then: the vertical scroll bar shows from the layout for the view's size, and stays though the content fits beside + // it, with nothing to scroll, so the view renders once + expect(view.hasVerticalScroller) == true + expect(state.layoutContainerSizes) == [CGSize(width: 120, height: 200), CGSize(width: 120 - thickness, height: 200)] + expect(renderCount) == 1 + expect(view.isScrollEnabled) == false + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120 - thickness, height: 200) + } + + func test_overlayScrollBars_layOutOnce() { + // given: a 120 × 200 view with overlay scroll bars, showing content as wide as the view and 300 pt tall + let state = WidthDependentNode.State() + let view = makeView(scrollerStyle: .overlay) { WidthDependentNode(state: state, height: { _ in 300 }) } + + // when: the view refreshes + view.refresh(animated: false) + + // then: the vertical scroll bar shows over the content, which lays out once, for the view's size + expect(view.hasVerticalScroller) == true + expect(state.layoutContainerSizes) == [CGSize(width: 120, height: 200)] + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120, height: 200) + } + + // MARK: - Scrolling and Resizing + + func test_scrolling_keepsTheScrollBars() { + // given: a 120 × 200 view with legacy scroll bars, showing content as wide as the view and 300 pt tall + let state = WidthDependentNode.State() + var containerSizes: [CGSize] = [] + let view = makeView { WidthDependentNode(state: state, height: { _ in 300 }) } + view.onWillLayout { _, context in + containerSizes.append(context.containerSize) + } + + // when: the view refreshes + view.refresh(animated: false) + + // then: the content lays out for the view's size, which decides the scroll bars, then for the render size beside the + // vertical scroll bar, and the will-layout handler runs before each layout + let renderSize = CGSize(width: 120 - thickness, height: 200) + expect(state.layoutContainerSizes) == [CGSize(width: 120, height: 200), renderSize] + expect(containerSizes) == [CGSize(width: 120, height: 200), renderSize] + + // when: the view scrolls + view.contentOffset = CGPoint(x: 0, y: 50) + view.layoutIfNeeded() + + // then: the pass keeps the scroll bars and lays out for the render size, which the cached layout already has, without + // laying out for the view's size again + expect(view.test.lastRenderBounds) == CGRect(origin: CGPoint(x: 0, y: 50), size: renderSize) + expect(state.layoutContainerSizes) == [CGSize(width: 120, height: 200), renderSize] + expect(containerSizes) == [CGSize(width: 120, height: 200), renderSize, renderSize] + } + + func test_resizing_decidesTheScrollBarsAgain() { + // given: a 120 × 200 view with legacy scroll bars that rendered content as wide as the view and 300 pt tall beside the + // vertical scroll bar + let view = makeView { WidthDependentNode(state: WidthDependentNode.State(), height: { _ in 300 }) } + view.refresh(animated: false) + expect(view.hasVerticalScroller) == true + + // when: the view grows taller than the content + view.frame.size = CGSize(width: 120, height: 400) + view.layoutIfNeeded() + + // then: the content fits, so the vertical scroll bar hides, and the content lays out for the whole view + expect(view.hasVerticalScroller) == false + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120, height: 400) + } + + // MARK: - Scroll Indicator Behavior + + func test_alwaysShownScrollBars_contentLaysOutBesideThemOnce() { + // given: a 120 × 200 view that always shows legacy scroll bars, showing content as wide as the view and 300 pt tall + let state = WidthDependentNode.State() + let view = makeView { WidthDependentNode(state: state, height: { _ in 300 }) } + view.scrollIndicatorBehavior = .always + + // when: the view refreshes + view.refresh(animated: false) + + // then: the content lays out once, beside both scroll bars + expect(state.layoutContainerSizes) == [CGSize(width: 120 - thickness, height: 200 - thickness)] + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120 - thickness, height: 200 - thickness) + } + + func test_manuallyShownScrollBar_contentLaysOutBesideItOnce() { + // given: a 120 × 200 view that leaves its legacy scroll bars to the caller, which shows the vertical one, showing + // content as wide as the view and 300 pt tall + let state = WidthDependentNode.State() + let view = makeView { WidthDependentNode(state: state, height: { _ in 300 }) } + view.scrollIndicatorBehavior = .manual + view.hasVerticalScroller = true + + // when: the view refreshes + view.refresh(animated: false) + + // then: the content lays out once, beside the vertical scroll bar + expect(view.hasHorizontalScroller) == false + expect(state.layoutContainerSizes) == [CGSize(width: 120 - thickness, height: 200)] + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120 - thickness, height: 200) + } + + // MARK: - Scroller Style + + func test_scrollerStyleChange_rendersForTheNewVisibleSize() { + // given: a 120 × 200 view in a window, with legacy scroll bars, that rendered content as wide as the view and 300 pt + // tall, beside the vertical scroll bar, and the window laid out + let window = TestWindow() + let view = makeView { WidthDependentNode(state: WidthDependentNode.State(), height: { _ in 300 }) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120 - thickness, height: 200) + + // when: the scroll bars switch to overlay, as when a mouse is disconnected, and the window lays out + view.scrollerStyle = .overlay + window.layoutIfNeeded() + + // then: the content lays out for the whole view, which the scroll bars no longer take space from + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120, height: 200) + expect(view.contentSize) == CGSize(width: 120, height: 300) + + // when: the scroll bars switch back to legacy, and the window lays out + view.scrollerStyle = .legacy + window.layoutIfNeeded() + + // then: the content lays out beside the vertical scroll bar again + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120 - thickness, height: 200) + expect(view.contentSize) == CGSize(width: 120 - thickness, height: 300) + } + + func test_scrollerStyleChangeToLegacy_decidesTheScrollBarsAgain() { + // given: a 120 × 200 view in a window, with overlay scroll bars, that rendered content 300 pt tall and 115 pt wide, + // which fits the view's width but not the width beside a legacy vertical scroll bar, and the window laid out + let window = TestWindow() + let view = makeView(scrollerStyle: .overlay) { LayerNode().frame(width: 115, height: 300) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + expect(view.hasVerticalScroller) == true + expect(view.hasHorizontalScroller) == false + + // when: the scroll bars switch to legacy, as when a mouse is connected, and the window lays out + view.scrollerStyle = .legacy + window.layoutIfNeeded() + + // then: the vertical scroll bar now takes space, so the content overflows beside it and the horizontal scroll bar + // shows too + expect(view.hasHorizontalScroller) == true + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120 - thickness, height: 200 - thickness) + } + + func test_resizeWithAScrollerStyleChangeThatKeepsTheVisibleSize_decidesTheScrollBarsAgain() { + // given: a 100 × 100 view in a window, with overlay scroll bars, showing content 110 pt wide and tall, so both scroll + // bars show, and the window laid out + let window = TestWindow() + let view = makeView(scrollerStyle: .overlay) { LayerNode().frame(width: 110, height: 110) } + view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + expect(view.hasHorizontalScroller) == true + expect(view.hasVerticalScroller) == true + + // when: the view grows by a legacy scroll bar's width in each direction and switches to legacy scroll bars, which + // take that space, so the visible size stays 100 × 100, and the window lays out + view.frame = CGRect(x: 0, y: 0, width: 100 + thickness, height: 100 + thickness) + view.scrollerStyle = .legacy + expect(view.visibleSize) == CGSize(width: 100, height: 100) + window.layoutIfNeeded() + + // then: the view renders for its new size, where the content fits, so both scroll bars hide and the content lays out + // for the whole view + expect(view.hasHorizontalScroller) == false + expect(view.hasVerticalScroller) == false + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 100 + thickness, height: 100 + thickness) + } + + // MARK: - Re-tiling + + func test_manuallyShowingAScrollBar_rendersBesideIt() { + // given: a 120 × 200 view in a window that leaves its legacy scroll bars to the caller, and rendered content as wide + // as the view and 300 pt tall without a scroll bar, and the window laid out + let window = TestWindow() + let view = makeView { WidthDependentNode(state: WidthDependentNode.State(), height: { _ in 300 }) } + view.scrollIndicatorBehavior = .manual + view.hasVerticalScroller = false + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120, height: 200) + + // when: the caller shows the vertical scroll bar, and the window lays out + view.hasVerticalScroller = true + window.layoutIfNeeded() + + // then: the content lays out beside the scroll bar + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120 - thickness, height: 200) + expect(view.contentSize) == CGSize(width: 120 - thickness, height: 300) + } + + func test_scrollerStyleChange_withoutScrollBars_doesNotLayOut() { + // given: a 120 × 200 view in a window, with legacy scroll bars, that rendered content that fits it, without scroll + // bars, and the window laid out + let window = TestWindow() + let view = makeView { LayerNode().frame(width: 100, height: 100) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + expect(view.hasVerticalScroller) == false + expect(view.hasHorizontalScroller) == false + view.layoutCount = 0 + + // when: the scroll bars switch to overlay, and the window lays out + view.scrollerStyle = .overlay + window.layoutIfNeeded() + + // then: no scroll bar takes space either way, so the visible size stays, and the view doesn't lay out + expect(view.visibleSize) == CGSize(width: 120, height: 200) + expect(view.layoutCount) == 0 + } + + func test_resizing_laysOutOnce() { + // given: a 120 × 200 view in a window, with legacy scroll bars, that rendered content as wide as the view and 300 pt + // tall, beside the vertical scroll bar, and the window laid out + let window = TestWindow() + let view = makeView { WidthDependentNode(state: WidthDependentNode.State(), height: { _ in 300 }) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + view.layoutCount = 0 + + // when: the view gets wider, the window lays out, and the run loop turns, where a follow-up layout would run + view.frame.size = CGSize(width: 150, height: 200) + window.layoutIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + // then: the view re-tiles as it resizes and as it lays out, and lays out once, rendering for the new visible size + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 150 - thickness, height: 200) + expect(view.layoutCount) == 1 + } + + func test_refreshShowingAScrollBar_doesNotLayOutAgain() { + // given: a 120 × 200 view in a window, with legacy scroll bars, that rendered content as wide as the view and 100 pt + // tall, without scroll bars, and the window laid out + var contentHeight: CGFloat = 100 + let window = TestWindow() + let view = makeView { WidthDependentNode(state: WidthDependentNode.State(), height: { _ in contentHeight }) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + expect(view.hasVerticalScroller) == false + view.layoutCount = 0 + + // when: the content gets taller than the view, the view refreshes, and the window lays out + contentHeight = 300 + view.refresh(animated: false) + window.layoutIfNeeded() + + // then: the refresh shows the vertical scroll bar and renders beside it, so the scroll bar's re-tile doesn't lay the + // view out again + expect(view.hasVerticalScroller) == true + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120 - thickness, height: 200) + expect(view.layoutCount) == 0 + } + + // MARK: - Scroll Position + + func test_refreshScrolledToTheBottom_keepsTheScrollPosition() { + // given: a 120 × 200 view in a window, with legacy scroll bars, showing thirty rows 115 pt wide and 10 pt tall, so + // it shows the horizontal scroll bar only because the vertical one takes width, scrolled to the bottom. the rows are + // shorter than the scroll bar, so a position off by the scroll bar's height leaves the last row out. + let window = TestWindow() + let view = makeView { + VStack { + for _ in 0 ..< 30 { + LayerNode().frame(width: 115, height: 10) + } + } + } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + let bottom = CGPoint(x: 0, y: 300 - (200 - thickness)) + view.contentOffset = bottom + window.layoutIfNeeded() + + // when: the view refreshes with the same content + view.refresh(animated: false) + + // then: the update hides the horizontal scroll bar on the way and shows it again, and the view stays at the bottom, + // with the last row rendered and fully visible + expect(view.hasHorizontalScroller) == true + expect(view.contentOffset) == bottom + expect(view.test.lastRenderBounds?.origin) == bottom + expect(view.test.lastRenderBounds?.maxY) == 300 + expect(renderedFrames(in: view).last) == CGRect(x: 0, y: 290, width: 115, height: 10) + } + + func test_refreshScrolledToTheRightEnd_keepsTheScrollPosition() { + // given: a 200 × 120 view in a window, with legacy scroll bars, showing thirty columns 10 pt wide and 115 pt tall, + // so it shows the vertical scroll bar only because the horizontal one takes height, scrolled to the right end. the + // columns are narrower than the scroll bar, so a position off by the scroll bar's width leaves the last column out. + let window = TestWindow() + let view = makeView { + HStack { + for _ in 0 ..< 30 { + LayerNode().frame(width: 10, height: 115) + } + } + } + view.frame = CGRect(x: 0, y: 0, width: 200, height: 120) + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + let rightEnd = CGPoint(x: 300 - (200 - thickness), y: 0) + view.contentOffset = rightEnd + window.layoutIfNeeded() + + // when: the view refreshes with the same content + view.refresh(animated: false) + + // then: the update hides the vertical scroll bar on the way and shows it again, and the view stays at the right end, + // with the last column rendered and fully visible + expect(view.hasVerticalScroller) == true + expect(view.contentOffset) == rightEnd + expect(view.test.lastRenderBounds?.origin) == rightEnd + expect(view.test.lastRenderBounds?.maxX) == 300 + expect(renderedFrames(in: view).last) == CGRect(x: 290, y: 0, width: 10, height: 115) + } + + func test_refreshWithTheSameContent_keepsAnOffsetOutsideTheScrollableRange() { + // given: a 120 × 200 view in a window, with legacy scroll bars, showing content 115 pt wide and 300 pt tall, so it + // shows the horizontal scroll bar only because the vertical one takes width, with the offset 30 pt past the bottom + let window = TestWindow() + let view = makeView { LayerNode().frame(width: 115, height: 300) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + let pastTheBottom = CGPoint(x: 0, y: 300 - (200 - thickness) + 30) + view.contentOffset = pastTheBottom + window.layoutIfNeeded() + + // when: the view refreshes with the same content + view.refresh(animated: false) + + // then: the update hides the horizontal scroll bar on the way and shows it again, and the offset stays past the + // bottom, as the view keeps an offset set outside the scrollable range + expect(view.hasHorizontalScroller) == true + expect(view.contentOffset) == pastTheBottom + expect(view.test.lastRenderBounds?.origin) == pastTheBottom + + // when: the offset is set 30 pt above the top, and the view refreshes with the same content + let aboveTheTop = CGPoint(x: 0, y: -30) + view.contentOffset = aboveTheTop + window.layoutIfNeeded() + view.refresh(animated: false) + + // then: the offset stays above the top + expect(view.hasHorizontalScroller) == true + expect(view.contentOffset) == aboveTheTop + expect(view.test.lastRenderBounds?.origin) == aboveTheTop + } + + func test_refreshWithShorterContent_clampsTheScrollPosition() { + // given: a 120 × 200 view in a window, with legacy scroll bars, showing content 115 pt wide and 300 pt tall, + // scrolled to the bottom + var contentHeight: CGFloat = 300 + let window = TestWindow() + let view = makeView { LayerNode().frame(width: 115, height: contentHeight) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + view.contentOffset = CGPoint(x: 0, y: 300 - (200 - thickness)) + window.layoutIfNeeded() + + // when: the content gets 250 pt tall, and the view refreshes + contentHeight = 250 + view.refresh(animated: false) + + // then: the bottom moved up, so the offset clamps to the new bottom + expect(view.contentOffset) == CGPoint(x: 0, y: 250 - (200 - thickness)) + + // when: the content gets 190 pt tall, which fits the view, and the view refreshes + contentHeight = 190 + view.refresh(animated: false) + + // then: both scroll bars hide, and the offset clamps to the top + expect(view.hasHorizontalScroller) == false + expect(view.hasVerticalScroller) == false + expect(view.contentOffset) == .zero + } + + func test_hidingTheScrollBarsAtTheBottom_clampsTheScrollPosition() { + // given: a 120 × 200 view in a window, with legacy scroll bars, showing content 300 pt wide and tall, scrolled to the + // bottom + let window = TestWindow() + let view = makeView { LayerNode().frame(width: 300, height: 300) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + view.contentOffset = CGPoint(x: 0, y: 300 - (200 - thickness)) + window.layoutIfNeeded() + + // when: the view stops showing scroll indicators, and refreshes + view.scrollIndicatorBehavior = .never + view.refresh(animated: false) + + // then: the content size stays, but the visible area grew, so the offset clamps to the new bottom + expect(view.contentSize) == CGSize(width: 300, height: 300) + expect(view.contentOffset) == CGPoint(x: 0, y: 300 - 200) + } + + func test_refreshThatHidesTheHorizontalScrollBar_keepsAnOffsetTheNewContentAllows() { + // given: a 120 × 200 view in a window, with legacy scroll bars, showing content 115 pt wide and 300 pt tall, scrolled + // to the bottom + var contentSize = CGSize(width: 115, height: 300) + let window = TestWindow() + let view = makeView { LayerNode().frame(width: contentSize.width, height: contentSize.height) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + let offset = CGPoint(x: 0, y: 300 - (200 - thickness)) + view.contentOffset = offset + window.layoutIfNeeded() + + // when: the content gets 100 pt wide, which fits beside the vertical scroll bar, and 400 pt tall, and the view + // refreshes + contentSize = CGSize(width: 100, height: 400) + view.refresh(animated: false) + + // then: the horizontal scroll bar hides, and the offset stays, since the taller content still allows it + expect(view.hasHorizontalScroller) == false + expect(view.contentOffset) == offset + expect(view.test.lastRenderBounds?.origin) == offset + } + + func test_refreshThatHidesTheVerticalScrollBar_keepsAnOffsetTheNewContentAllows() { + // given: a 200 × 120 view in a window, with legacy scroll bars, showing content 300 pt wide and 115 pt tall, scrolled + // to the right end + var contentSize = CGSize(width: 300, height: 115) + let window = TestWindow() + let view = makeView { LayerNode().frame(width: contentSize.width, height: contentSize.height) } + view.frame = CGRect(x: 0, y: 0, width: 200, height: 120) + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + let offset = CGPoint(x: 300 - (200 - thickness), y: 0) + view.contentOffset = offset + window.layoutIfNeeded() + + // when: the content gets 400 pt wide and 100 pt tall, which fits above the horizontal scroll bar, and the view + // refreshes + contentSize = CGSize(width: 400, height: 100) + view.refresh(animated: false) + + // then: the vertical scroll bar hides, and the offset stays, since the wider content still allows it + expect(view.hasVerticalScroller) == false + expect(view.contentOffset) == offset + expect(view.test.lastRenderBounds?.origin) == offset + } + + func test_willLayoutHandlerSettingTheOffsetBesideTheScrollBar_keepsItsOffset() { + // given: a 120 × 200 view in a window, with legacy scroll bars, showing content 115 pt wide and 300 pt tall, scrolled + // to the bottom, and a will-layout handler that sets the offset on the second layout, beside the vertical scroll bar + let window = TestWindow() + let view = makeView { LayerNode().frame(width: 115, height: 300) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + view.contentOffset = CGPoint(x: 0, y: 300 - (200 - thickness)) + window.layoutIfNeeded() + var layoutCount = 0 + view.onWillLayout { view, _ in + layoutCount += 1 + if layoutCount == 2 { + view.contentOffset = CGPoint(x: 0, y: 40) + } + } + + // when: the view refreshes with the same content + view.refresh(animated: false) + + // then: the update hides the horizontal scroll bar on the way and shows it again, and the handler's offset stays + expect(layoutCount) == 3 + expect(view.hasHorizontalScroller) == true + expect(view.contentOffset) == CGPoint(x: 0, y: 40) + expect(view.test.lastRenderBounds?.origin) == CGPoint(x: 0, y: 40) + } + + func test_willLayoutHandlerSettingTheOffsetOnTheFirstLayout_laterLayoutsReportIt() { + // given: a 120 × 200 view in a window, with legacy scroll bars, showing content 115 pt wide and 300 pt tall, so it + // shows the horizontal scroll bar only because the vertical one takes width, and a will-layout handler that records + // the viewports it gets and sets the offset on the first layout + let window = TestWindow() + let view = makeView { LayerNode().frame(width: 115, height: 300) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + var reportedBounds: [CGRect] = [] + view.onWillLayout { view, context in + switch context.renderType { + case .refresh: + break + case .boundsChange(_, let bounds): + reportedBounds.append(bounds) + if reportedBounds.count == 1 { + view.contentOffset = CGPoint(x: 0, y: 40) + } + } + } + + // when: the view gets 1 pt wider, so it lays out for its full size, then beside the vertical scroll bar, then beside + // both scroll bars + view.frame = CGRect(x: 0, y: 0, width: 121, height: 200) + window.layoutIfNeeded() + + // then: each layout reports the current offset, so the layouts after the handler's change report its offset, which + // the pass renders + expect(reportedBounds) == [ + CGRect(x: 0, y: 0, width: 121, height: 200), + CGRect(x: 0, y: 40, width: 121 - thickness, height: 200), + CGRect(x: 0, y: 40, width: 121 - thickness, height: 200 - thickness), + ] + expect(view.test.lastRenderBounds?.origin) == CGPoint(x: 0, y: 40) + } + + func test_willLayoutHandlerChangingTheBehaviorOnTheSecondLayout_assertsAndKeepsIt() { + // given: a 120 × 200 view with legacy scroll bars, showing content larger than the view, a will-layout handler that + // switches to never showing the scroll indicators on the second layout, after the scroll bars take space, and a + // handler that records the assertions + let view = makeView { LayerNode().frame(width: 300, height: 300) } + var layoutCount = 0 + view.onWillLayout { view, _ in + layoutCount += 1 + if layoutCount == 2 { + view.scrollIndicatorBehavior = .never + } + } + + var assertionMessages: [String] = [] + ComposeUI.Assert.setTestAssertionFailureHandler { message, _, _, _ in + assertionMessages.append(message) + } + defer { + ComposeUI.Assert.resetTestAssertionFailureHandler() + } + + // when: the view refreshes + view.refresh(animated: false) + + // then: the change asserts, and the pass keeps the automatic behavior, so both scroll bars show and the content lays + // out for the space they leave + expect(assertionMessages) == ["onWillLayout can't change scrollBehavior, scrollIndicatorBehavior or clippingBehavior"] + expect(view.scrollIndicatorBehavior) == .auto + expect(view.hasHorizontalScroller) == true + expect(view.hasVerticalScroller) == true + expect(view.test.lastRenderBounds?.size) == CGSize(width: 120 - thickness, height: 200 - thickness) + } + + func test_willLayoutHandlerShowingAScrollBarForTheContainerSize_assertsAndKeepsIt() { + // given: a 120 × 200 view in a window, with manual legacy scroll bars, both hidden, showing content 100 pt wide and + // tall, a will-layout handler that shows the vertical scroll bar when the container is wider than 110 pt, which the + // scroll bar itself would make it not, and a handler that records the assertions + let window = TestWindow() + let view = makeView { LayerNode().frame(width: 100, height: 100) } + view.scrollIndicatorBehavior = .manual + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + window.contentView().addSubview(view) + var layoutCount = 0 + view.onWillLayout { view, context in + layoutCount += 1 + view.showsVerticalScrollIndicator = context.containerSize.width > 110 + } + + var assertionMessages: [String] = [] + ComposeUI.Assert.setTestAssertionFailureHandler { message, _, _, _ in + assertionMessages.append(message) + } + defer { + ComposeUI.Assert.resetTestAssertionFailureHandler() + } + + // when: the view refreshes, and the run loop runs twice + view.refresh(animated: false) + for _ in 0 ..< 2 { + var isDrained = false + RunLoop.main.perform { isDrained = true } + expect(isDrained).toEventually(beTrue()) + } + + // then: the change of the visible size asserts, and the pass keeps the scroll bar hidden and lays out once, for the + // whole view, so no follow-up pass shows it + expect(assertionMessages) == ["onWillLayout can't change the visible size"] + expect(view.hasVerticalScroller) == false + expect(layoutCount) == 1 + expect(view.test.lastRenderBounds?.size) == CGSize(width: 120, height: 200) + } + + func test_willLayoutHandlerSwitchingTheScrollerStyleForTheContainerSize_assertsAndKeepsIt() { + // given: a 120 × 200 view in a window, with overlay scroll bars, showing content 100 pt wide and 300 pt tall, so it + // shows the vertical scroll bar, a will-layout handler that switches to legacy scroll bars for the view's full width + // and back to overlay ones otherwise, and a handler that records the assertions + let window = TestWindow() + let view = makeView(scrollerStyle: .overlay) { LayerNode().frame(width: 100, height: 300) } + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + var layoutCount = 0 + view.onWillLayout { view, context in + layoutCount += 1 + view.scrollerStyle = context.containerSize.width == 120 ? .legacy : .overlay + } + + var assertionMessages: [String] = [] + ComposeUI.Assert.setTestAssertionFailureHandler { message, _, _, _ in + assertionMessages.append(message) + } + defer { + ComposeUI.Assert.resetTestAssertionFailureHandler() + } + + // when: the view refreshes, and the run loop runs twice + view.refresh(animated: false) + for _ in 0 ..< 2 { + var isDrained = false + RunLoop.main.perform { isDrained = true } + expect(isDrained).toEventually(beTrue()) + } + + // then: the change of the visible size asserts, and the pass keeps the overlay scroll bars and lays out once, for the + // whole view, so no follow-up pass switches the style again + expect(assertionMessages) == ["onWillLayout can't change the visible size"] + expect(view.scrollerStyle) == .overlay + expect(layoutCount) == 1 + expect(view.test.lastRenderBounds?.size) == CGSize(width: 120, height: 200) + } + + func test_didRenderHandlerResizingWithAScrollerStyleChangeThatKeepsTheVisibleSize_rendersAgain() { + // given: a 100 × 100 view in a window, with overlay scroll bars, showing content 110 pt wide and tall, so both scroll + // bars show, the window laid out, and a did-render handler that once grows the view by a legacy scroll bar's width in + // each direction and switches to legacy scroll bars, which take that space, so the visible size stays 100 × 100 + let window = TestWindow() + let view = makeView(scrollerStyle: .overlay) { LayerNode().frame(width: 110, height: 110) } + view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + window.contentView().addSubview(view) + view.refresh(animated: false) + window.layoutIfNeeded() + let grownFrame = CGRect(x: 0, y: 0, width: 100 + thickness, height: 100 + thickness) + var resizes = true + view.onDidRender { view, _ in + guard resizes else { + return + } + resizes = false + view.frame = grownFrame + view.scrollerStyle = .legacy + } + + // when: the view refreshes during the window's layout, which ignores the layout the resize requests, and the run loop + // runs the pass that follows it + view.setNeedsRefresh(animated: false) + window.layoutIfNeeded() + var isDrained = false + RunLoop.main.perform { isDrained = true } + expect(isDrained).toEventually(beTrue()) + + // then: the view renders again for its new size, where the content fits, so both scroll bars hide and the content + // lays out for the whole view + expect(view.hasHorizontalScroller) == false + expect(view.hasVerticalScroller) == false + expect(view.test.lastRenderBounds) == grownFrame + } + + // MARK: - Helpers + + /// Makes a 120 × 200 view that shows scroll bars of the style for the axes its content overflows. + private func makeView(scrollerStyle: NSScroller.Style = .legacy, @ComposeContentBuilder content: @escaping () -> ComposeContent) -> LayoutCountingComposeView { + let view = LayoutCountingComposeView(content: content) + view.frame = CGRect(x: 0, y: 0, width: 120, height: 200) + view.scrollIndicatorBehavior = .auto + view.scrollerStyle = scrollerStyle + return view + } + + /// Returns the frames of the view's rendered layers in content coordinates, ordered by position. + private func renderedFrames(in view: ComposeView) -> [CGRect] { + let frames = view.contentContainerView.layer?.sublayers?.map(\.frame) ?? [] + return frames.sorted { ($0.minY, $0.minX) < ($1.minY, $1.minX) } + } +} + +/// A node as wide as its container, with a height that the width decides, that records the container sizes it lays out +/// for. +private struct WidthDependentNode: ComposeNode { + + final class State { + + /// The container sizes the node laid out for, in order. + var layoutContainerSizes: [CGSize] = [] + } + + private let state: State + private let height: (_ width: CGFloat) -> CGFloat + + init(state: State, height: @escaping (_ width: CGFloat) -> CGFloat) { + self.state = state + self.height = height + } + + var id: ComposeNodeId = .custom("width-dependent", isFixed: false) + + var size: CGSize = .zero + + mutating func layout(containerSize: CGSize, context: ComposeNodeLayoutContext) -> ComposeNodeSizing { + state.layoutContainerSizes.append(containerSize) + size = CGSize(width: containerSize.width, height: height(containerSize.width)) + return ComposeNodeSizing(width: .flexible, height: .fixed(size.height)) + } + + func renderableItems(in visibleBounds: CGRect) -> [RenderableItem] { + [] + } +} + +/// A view that counts its layouts. +private final class LayoutCountingComposeView: ComposeView { + + /// The number of times the view laid out. + var layoutCount = 0 + + override func layout() { + layoutCount += 1 + super.layout() + } +} +#endif diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift index 8b4bcdf..affc598 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift @@ -90,20 +90,21 @@ class ComposeView_RenderBoundsTests: XCTestCase { expect(updateCount) == 1 - // then: expect the contentUpdateContext is set with correct render bounds + // then: expect the contentUpdateContext is set with the view's bounds at the content offset + let visibleSize = view.visibleSize let initialContext = try unwrap(invokedContentUpdateContext) var expectedContext = ComposeView.ContentUpdateContext( contentNode: initialContext.contentNode, contentEvaluation: initialContext.contentEvaluation, updateType: .boundsChange, previousRenderBounds: nil, - renderBounds: CGRect(x: 0, y: 0, width: 120, height: 80), + bounds: CGRect(x: 0, y: 0, width: 120, height: 80), preparedAnimationDecision: .all ) expect(invokedContentUpdateContext) == expectedContext - // then: lastRenderBounds does not consider the scrollers - expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120, height: 80) + // then: lastRenderBounds is the visible area, which the scrollers take space from on AppKit + expect(view.test.lastRenderBounds) == CGRect(origin: .zero, size: visibleSize) // reset invokedContentUpdateContext = nil @@ -115,7 +116,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { // then: should not update as no bounds change expect(updateCount) == 1 expect(invokedContentUpdateContext) == nil - expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 120, height: 80) + expect(view.test.lastRenderBounds) == CGRect(origin: .zero, size: visibleSize) // when: adjust scroll position and layout again view.contentOffset = CGPoint(x: 0, y: 10) @@ -129,13 +130,13 @@ class ComposeView_RenderBoundsTests: XCTestCase { contentNode: initialContext.contentNode, contentEvaluation: initialContext.contentEvaluation, updateType: .boundsChange, - previousRenderBounds: CGRect(x: 0, y: 0, width: 120, height: 80), - renderBounds: CGRect(x: 0, y: 10, width: 120, height: 80), + previousRenderBounds: CGRect(origin: .zero, size: visibleSize), + bounds: CGRect(x: 0, y: 10, width: 120, height: 80), preparedAnimationDecision: .all ) expect(invokedContentUpdateContext) == expectedContext - expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 10, width: 120, height: 80) + expect(view.test.lastRenderBounds) == CGRect(origin: CGPoint(x: 0, y: 10), size: visibleSize) } #if canImport(AppKit) @@ -159,15 +160,16 @@ class ComposeView_RenderBoundsTests: XCTestCase { 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 - contentSize = CGSize(width: 100, height: 250) + // narrows them to fit beside the vertical scroller, which hides the horizontal scroller and clamps the offset again + let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + contentSize = CGSize(width: 100 - thickness, height: 250) view.refresh(animated: false) - // then: the pass renders the rows at the offset the view ends up with + // then: the pass renders the rows at the offset the view ends up with, beside the vertical scroller expect(view.hasHorizontalScroller) == false 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) } + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 150, width: 100 - thickness, height: 100) + expect(renderedFrames(in: view)) == (15 ..< 25).map { CGRect(x: 0, y: CGFloat($0) * 10, width: 100 - thickness, height: 10) } } func test_renderBounds_hidingLegacyScroller_scrolledToRightEdge() { @@ -190,20 +192,23 @@ class ComposeView_RenderBoundsTests: XCTestCase { 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 - contentSize = CGSize(width: 250, height: 100) + // shortens them to fit beside the horizontal scroller, which hides the vertical scroller and clamps the offset again + let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + contentSize = CGSize(width: 250, height: 100 - thickness) view.refresh(animated: false) - // then: the pass renders the columns at the offset the view ends up with + // then: the pass renders the columns at the offset the view ends up with, beside the horizontal scroller expect(view.hasVerticalScroller) == false 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) } + expect(view.test.lastRenderBounds) == CGRect(x: 150, y: 0, width: 100, height: 100 - thickness) + expect(renderedFrames(in: view)) == (15 ..< 25).map { CGRect(x: CGFloat($0) * 10, y: 0, width: 10, height: 100 - thickness) } } func test_renderBounds_showingLegacyScroller_scrolledToBottom() { - // given: a view with legacy scrollers, scrolled to the bottom of rows that overflow only vertically - var contentSize = CGSize(width: 100, height: 300) + // given: a view with legacy scrollers, scrolled to the bottom of rows that overflow only vertically, fitting beside + // the vertical scroller + let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + var contentSize = CGSize(width: 100 - thickness, height: 300) let view = ComposeView { VStack { for _ in 0 ..< Int(contentSize.height / 10) { @@ -225,11 +230,11 @@ class ComposeView_RenderBoundsTests: XCTestCase { contentSize = CGSize(width: 200, height: 250) view.refresh(animated: false) - // then: the pass renders the rows at the offset the view ends up with + // then: the pass renders the rows at the offset the view ends up with, the new end beside both scrollers expect(view.hasHorizontalScroller) == true - 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) } + expect(view.contentOffset) == CGPoint(x: 0, y: 150 + thickness) + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 150 + thickness, width: 100 - thickness, height: 100 - thickness) + expect(renderedFrames(in: view)) == (16 ..< 25).map { CGRect(x: 0, y: CGFloat($0) * 10, width: 200, height: 10) } } func test_renderBounds_legacyScrollers_scrolledToTheEnd_keepsTheOffset() { @@ -257,15 +262,18 @@ class ComposeView_RenderBoundsTests: XCTestCase { 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 + // then: the view stays at the end, rendered once for the visible area beside the scrollers, with the rows that fill + // the viewport + let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) expect(view.contentOffset) == CGPoint(x: 0, y: maxOffsetY) - expect(renderBounds) == [CGRect(x: 0, y: maxOffsetY, width: 100, height: 100)] + expect(renderBounds) == [CGRect(x: 0, y: maxOffsetY, width: 100 - thickness, height: 100 - thickness)] expect(renderedFrames(in: view)) == (21 ..< 30).map { CGRect(x: 0, y: CGFloat($0) * 10, width: 200, height: 10) } } func test_renderBounds_legacyScrollers_scrolledSideways_keepsTheOffset() { - // given: a view with legacy scrollers, showing rows as wide as the view that overflow only vertically, so the shown - // vertical scroller leaves a scroller thickness of the rows to scroll to sideways + // given: a view with legacy scrollers, showing rows as wide as the view that overflow vertically. they don't fit + // beside the vertical scroller, so the horizontal scroller shows too, with a scroller thickness of the rows to scroll + // to sideways let view = ComposeView { VStack { for _ in 0 ..< 30 { @@ -276,7 +284,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) useLegacyScrollers(view) view.refresh(animated: false) - expect(view.hasHorizontalScroller) == false + expect(view.hasHorizontalScroller) == true expect(view.hasVerticalScroller) == true var renderBounds: [CGRect] = [] @@ -288,9 +296,10 @@ class ComposeView_RenderBoundsTests: XCTestCase { 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 + // then: the view stays where it's scrolled to, rendered once for the visible area beside the scrollers + let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) expect(view.contentOffset) == CGPoint(x: 10, y: 50) - expect(renderBounds) == [CGRect(x: 10, y: 50, width: 100, height: 100)] + expect(renderBounds) == [CGRect(x: 10, y: 50, width: 100 - thickness, height: 100 - thickness)] } func test_renderBounds_legacyScrollers_contentShrinksAtTheEnd_rendersOnce() { @@ -320,14 +329,15 @@ class ComposeView_RenderBoundsTests: XCTestCase { rowCount = 25 view.refresh(animated: false) - // then: the view renders once, at the new end, for the full view size + // then: the view renders once, at the new end, for the visible area beside the scrollers + let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) expect(view.hasHorizontalScroller) == true expect(view.hasVerticalScroller) == true expect(view.contentOffset) == CGPoint(x: 0, y: view.maxOffsetY) - expect(renderBounds) == [CGRect(x: 0, y: view.maxOffsetY, width: 100, height: 100)] + expect(renderBounds) == [CGRect(x: 0, y: view.maxOffsetY, width: 100 - thickness, height: 100 - thickness)] } - func test_renderBounds_legacyScrollers_sizeFollowsTheFrame() { + func test_renderBounds_legacyScrollers_sizeFollowsTheVisibleSize() { // given: a 120 × 80 view with content insets, showing both legacy scrollers let view = ComposeView { LayerNode().frame(width: 300, height: 300) @@ -339,7 +349,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { // when: the view refreshes view.refresh(animated: false) - // then: the scrollers show over the clip view with the insets, and the content lays out for the frame + // then: the scrollers show over the clip view with the insets, so the content lays out for the whole frame expect(view.hasHorizontalScroller) == true expect(view.hasVerticalScroller) == true expect(view.contentView.frame.size) == CGSize(width: 120, height: 80) @@ -349,10 +359,11 @@ class ComposeView_RenderBoundsTests: XCTestCase { view.contentInsets = NSEdgeInsetsZero view.refresh(animated: false) - // then: the size stays + // then: the content lays out for the visible size beside the scrollers let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) expect(view.contentView.frame.size) == CGSize(width: 120 - thickness, height: 80 - thickness) - expect(view.test.lastRenderBounds?.size) == CGSize(width: 120, height: 80) + expect(view.test.lastRenderBounds?.size) == CGSize(width: 120 - thickness, height: 80 - thickness) + expect(view.visibleSize) == CGSize(width: 120 - thickness, height: 80 - thickness) } func test_renderBounds_scaledBounds_rendersTheScaledViewport() { @@ -372,12 +383,13 @@ class ComposeView_RenderBoundsTests: XCTestCase { view.setBoundsSize(CGSize(width: 400, height: 200)) view.layoutIfNeeded() - // then: the content lays out and renders for the scaled viewport, 20 rows 400 pt wide - expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 400, height: 200) - expect(renderedFrames(in: view)) == (0 ..< 20).map { CGRect(x: 0, y: CGFloat($0) * 10, width: 400, height: 10) } + // then: the content lays out and renders for the scaled viewport beside the vertical scroller, 20 rows + let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 0, width: 400 - thickness, height: 200) + expect(renderedFrames(in: view)) == (0 ..< 20).map { CGRect(x: 0, y: CGFloat($0) * 10, width: 400 - thickness, height: 10) } } - func test_renderBounds_legacyScrollerMovedInByTheRightContentInset_rendersTheFullWidth() throws { + func test_renderBounds_legacyScrollerMovedInByTheRightContentInset_rendersTheVisibleWidth() throws { // given: a 240 × 180 view with a 30 pt right content inset and legacy scrollers, showing flexible rows that overflow // vertically, so AppKit shrinks the clip view by the vertical scroller and moves the scroller inside it by the inset var rowWidth: CGFloat = 0 @@ -399,25 +411,26 @@ class ComposeView_RenderBoundsTests: XCTestCase { // when: the view refreshes view.refresh(animated: false) - // then: the rows lay out for the full view width, and only the vertical scroller shows + // then: the rows lay out for the visible width beside the vertical scroller, and only the vertical scroller shows let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) let verticalScroller = try view.verticalScroller.unwrap() expect(view.contentView.frame.width) == 240 - thickness expect(verticalScroller.frame.maxX) < view.contentView.frame.maxX expect(view.hasVerticalScroller) == true expect(view.hasHorizontalScroller) == false - expect(renderedFrames(in: view)) == (0 ..< 18).map { CGRect(x: 0, y: CGFloat($0) * 10, width: 240, height: 10) } + expect(renderedFrames(in: view)) == (0 ..< 18).map { CGRect(x: 0, y: CGFloat($0) * 10, width: 240 - thickness, height: 10) } - // when: the rows become 230 pt wide, which fits the view width but not the clip view + // when: the rows become 230 pt wide, which fits the view width but not the visible width beside the vertical scroller rowWidth = 230 view.refresh(animated: false) - // then: the rows fit, centered in the view width, so the horizontal scroller stays hidden - expect(view.hasHorizontalScroller) == false - expect(renderedFrames(in: view)) == (0 ..< 18).map { CGRect(x: 5, y: CGFloat($0) * 10, width: 230, height: 10) } + // then: the rows overflow beside the vertical scroller, so the horizontal scroller shows too, and the rows lay out for + // the visible height above it + expect(view.hasHorizontalScroller) == true + expect(renderedFrames(in: view)) == (0 ..< 17).map { CGRect(x: 0, y: CGFloat($0) * 10, width: 230, height: 10) } } - func test_renderBounds_legacyScrollerMovedInByTheBottomContentInset_rendersTheFullHeight() throws { + func test_renderBounds_legacyScrollerMovedInByTheBottomContentInset_centersInTheVisibleHeight() throws { // given: a 240 × 180 view with a 30 pt bottom content inset and legacy scrollers, showing five 400 pt wide rows that // overflow horizontally, so AppKit shrinks the clip view by the horizontal scroller and moves the scroller inside it // by the inset @@ -435,14 +448,16 @@ class ComposeView_RenderBoundsTests: XCTestCase { // when: the view refreshes view.refresh(animated: false) - // then: the rows center in the full view height, and only the horizontal scroller shows + // then: the rows center in the visible height above the horizontal scroller, and only the horizontal scroller shows let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) let horizontalScroller = try view.horizontalScroller.unwrap() expect(view.contentView.frame.height) == 180 - thickness expect(horizontalScroller.frame.maxY) < view.contentView.frame.maxY expect(view.hasHorizontalScroller) == true expect(view.hasVerticalScroller) == false - expect(renderedFrames(in: view)) == (0 ..< 5).map { CGRect(x: 0, y: 65 + CGFloat($0) * 10, width: 400, height: 10) } + let top = (180 - thickness - 50) / 2 + let scale = view.contentScaleFactor + expect(renderedFrames(in: view)) == (0 ..< 5).map { CGRect(x: 0, y: ((top + CGFloat($0) * 10) * scale).rounded() / scale, width: 400, height: 10) } } func test_renderBounds_fractionalViewSize_clipViewRoundsDown_staysExact() { @@ -616,9 +631,10 @@ class ComposeView_RenderBoundsTests: XCTestCase { view.frame.size = CGSize(width: 140, height: 140) view.layoutIfNeeded() - // then: the view renders once, after the tiling, for the new size at the clamped offset + // then: the view renders once, after the tiling, for the visible area of the new size at the clamped offset + let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) expect(view.contentOffset) == CGPoint(x: 0, y: view.maxOffsetY) - expect(renderBounds) == [CGRect(x: 0, y: view.maxOffsetY, width: 140, height: 140)] + expect(renderBounds) == [CGRect(x: 0, y: view.maxOffsetY, width: 140 - thickness, height: 140 - thickness)] } /// Makes the view show legacy scrollers for the axes its content overflows, so a shown scroller shrinks the clip view. diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift index 1d734a3..89c3ec9 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift @@ -238,6 +238,145 @@ class ComposeView_RenderHandlerTests: XCTestCase { expect(didRenderRenderType) == .boundsChange(previousBounds: CGRect(x: 0, y: 50, width: 100, height: 100), bounds: CGRect(x: 0, y: 50, width: 150, height: 150)) } + func test_willLayoutHandler_autoScrollIndicatorBehavior_runsOncePerPass() { + // given: a 100 × 100 view that shows its scroll indicators automatically, as overlay scroll bars on macOS, with + // content taller than the view + let view = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + view.setContent { + ColorNode(.red) + .frame(width: .flexible, height: 200) + } + view.scrollIndicatorBehavior = .auto + #if canImport(AppKit) + view.scrollerStyle = .overlay + #endif + + var containerSizes: [CGSize] = [] + view.onWillLayout { _, context in + containerSizes.append(context.containerSize) + } + + // when: the view refreshes + view.refresh(animated: false) + + // then: the vertical scroll indicator shows without taking space, so the layout that decides it is the pass's only + // layout, and the handler runs once + expect(view.showsVerticalScrollIndicator) == true + expect(containerSizes) == [CGSize(width: 100, height: 100)] + + // when: the view resizes + view.frame.size = CGSize(width: 100, height: 150) + view.layoutIfNeeded() + + // then: the handler runs once more, for the new size + expect(containerSizes) == [CGSize(width: 100, height: 100), CGSize(width: 100, height: 150)] + } + + func test_willLayoutHandler_changingTheScrollSettings_assertsAndKeepsThem() { + // given: a 100 × 100 view with automatic scroll settings, as overlay scroll bars on macOS, content taller than the + // view, a will-layout handler that sets the scroll settings, and a handler that records the assertions + let view = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + view.setContent { + ColorNode(.red) + .frame(width: .flexible, height: 200) + } + view.scrollBehavior = .auto + view.scrollIndicatorBehavior = .auto + view.clippingBehavior = .auto + #if canImport(AppKit) + view.scrollerStyle = .overlay + #endif + var settings: (ComposeView.ScrollBehavior, ComposeView.ScrollIndicatorBehavior, ComposeView.ClippingBehavior) = (.auto, .auto, .auto) + view.onWillLayout { view, _ in + (view.scrollBehavior, view.scrollIndicatorBehavior, view.clippingBehavior) = settings + } + + var assertionMessages: [String] = [] + ComposeUI.Assert.setTestAssertionFailureHandler { message, _, _, _ in + assertionMessages.append(message) + } + defer { + ComposeUI.Assert.resetTestAssertionFailureHandler() + } + + // when: the handler sets the settings the view already has, and the view refreshes + view.refresh(animated: false) + + // then: nothing asserts + expect(assertionMessages) == [] + + // when: the handler sets other settings, and the view refreshes + settings = (.never, .never, .never) + view.refresh(animated: false) + + // then: it asserts and keeps the automatic settings, so the content taller than the view scrolls, shows the vertical + // scroll indicator, and clips + expect(assertionMessages) == ["onWillLayout can't change scrollBehavior, scrollIndicatorBehavior or clippingBehavior"] + expect(view.scrollBehavior) == .auto + expect(view.scrollIndicatorBehavior) == .auto + expect(view.clippingBehavior) == .auto + expect(view.isScrollEnabled) == true + expect(view.showsVerticalScrollIndicator) == true + expect(view.clipsToBounds) == true + } + + func test_willLayoutHandler_showingScrollIndicatorsThatTakeNoSpace_keepsThem() { + // given: a 100 × 100 view with manual scroll indicators, both hidden, as overlay scroll bars on macOS, which take no + // space, content taller than the view, a will-layout handler that shows the scroll indicators, and a handler that + // records the assertions + let view = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + view.setContent { + ColorNode(.red) + .frame(width: .flexible, height: 200) + } + view.scrollIndicatorBehavior = .manual + view.showsHorizontalScrollIndicator = false + view.showsVerticalScrollIndicator = false + #if canImport(AppKit) + view.scrollerStyle = .overlay + #endif + view.onWillLayout { view, _ in + view.showsHorizontalScrollIndicator = true + view.showsVerticalScrollIndicator = true + } + + var assertionMessages: [String] = [] + ComposeUI.Assert.setTestAssertionFailureHandler { message, _, _, _ in + assertionMessages.append(message) + } + defer { + ComposeUI.Assert.resetTestAssertionFailureHandler() + } + + // when: the view refreshes + view.refresh(animated: false) + + // then: the scroll indicators leave the visible size as it was, so nothing asserts, and they show + expect(assertionMessages) == [] + expect(view.showsHorizontalScrollIndicator) == true + expect(view.showsVerticalScrollIndicator) == true + expect(view.test.lastRenderBounds?.size) == CGSize(width: 100, height: 100) + } + + func test_willLayoutHandler_changingTheContentScaleFactor_laysOutAtTheNewScale() { + // given: a 100 × 100 view at scale 1, showing a node 100.25 pt square that rounds its size up to whole pixels, and a + // will-layout handler that sets the scale to 2 + let view = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + view.setContent { + PixelRoundedNode(size: CGSize(width: 100.25, height: 100.25)) + } + view.contentScaleFactor = 1 + view.onWillLayout { view, _ in + view.contentScaleFactor = 2 + } + + // when: the view refreshes + view.refresh(animated: false) + + // then: the node lays out at the new scale, so its size rounds up to the next half point + expect(view.contentSize) == CGSize(width: 100.5, height: 100.5) + } + func test_willRenderHandler() { // given: a compose view with a content let view = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) @@ -650,3 +789,28 @@ class ComposeView_RenderHandlerTests: XCTestCase { expect(layerContexts.first?.animationDecision) == ComposeView.AnimationDecision.all } } + +/// A node that lays out to its size rounded up to whole pixels at the scale of its layout context. +private struct PixelRoundedNode: ComposeNode { + + private let fixedSize: CGSize + + init(size: CGSize) { + self.fixedSize = size + } + + // MARK: - ComposeNode + + var id: ComposeNodeId = .custom("pixel-rounded", isFixed: false) + + private(set) var size: CGSize = .zero + + mutating func layout(containerSize: CGSize, context: ComposeNodeLayoutContext) -> ComposeNodeSizing { + size = fixedSize.roundedUp(scaleFactor: context.scaleFactor) + return ComposeNodeSizing(width: .fixed(size.width), height: .fixed(size.height)) + } + + func renderableItems(in visibleBounds: CGRect) -> [RenderableItem] { + [] + } +} diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderOverrideTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderOverrideTests.swift index d86737e..0e50f9c 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderOverrideTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderOverrideTests.swift @@ -282,6 +282,121 @@ class ComposeView_RenderOverrideTests: XCTestCase { expect(row.backgroundColor) == Color.blue.cgColor expect(row.frame) == CGRect(x: 0, y: 0, width: 160, height: 100) } + + func test_render_heldByAnOverride_scrolledWhileHeld_reportsTheCurrentOffsetToTheWillLayoutHandler() { + // given: a view, with overlay scroll bars on macOS so it lays out once, holding a render pass for a resize, then + // scrolled while holding it, with a will-layout handler that records the render types + var renderTypes: [ComposeView.RenderType] = [] + let view = RenderHoldingView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + #if canImport(AppKit) + view.scrollerStyle = .overlay + #endif + view.setContent { + ColorNode(.red) + .frame(width: .flexible, height: 300) + } + view.refresh(animated: false) + view.holdsRenderPass = true + view.frame.size.width = 160 + view.setNeedsLayout() + view.layoutIfNeeded() + view.contentOffset = CGPoint(x: 0, y: 50) + view.layoutIfNeeded() + view.onWillLayout { _, context in + renderTypes.append(context.renderType) + } + + // when: the held pass is released + view.releaseRenderPass() + + // then: the will-layout handler gets the offset the view scrolled to, where the pass renders, instead of the one the + // pass was prepared with + expect(renderTypes) == [ + .boundsChange(previousBounds: CGRect(x: 0, y: 0, width: 100, height: 100), bounds: CGRect(x: 0, y: 50, width: 160, height: 100)), + ] + expect(view.test.lastRenderBounds) == CGRect(x: 0, y: 50, width: 160, height: 100) + } + + #if canImport(AppKit) + func test_render_heldByAnOverride_afterTheViewResized_withLegacyScrollBar_rendersBesideTheScrollBar() throws { + // given: a view showing a legacy vertical scroll bar, holding a render pass prepared with new content, then resized + // and laid out while holding it + var color = Color.red + var layer: CALayer? + let view = RenderHoldingView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + view.scrollIndicatorBehavior = .manual + view.scrollerStyle = .legacy + view.hasVerticalScroller = true + view.setContent { + ColorNode(color) + .frame(width: .flexible, height: 100) + .onUpdate { renderable, _ in + layer = renderable.layer + } + } + view.refresh(animated: false) + let row = try unwrap(layer) + view.holdsRenderPass = true + color = .blue + view.refresh(animated: false) + view.frame.size.width = 160 + view.setNeedsLayout() + view.layoutIfNeeded() + + // when: the held pass is released + view.releaseRenderPass() + + // then: the held pass renders its content for the bounds it was prepared with, less the space the scroll bar takes + let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + expect(row.backgroundColor) == Color.blue.cgColor + expect(row.frame) == CGRect(x: 0, y: 0, width: 100 - thickness, height: 100) + + // when: the run loop performs the follow-up layout + var isDrained = false + RunLoop.main.perform { isDrained = true } + expect(isDrained).toEventually(beTrue()) + + // then: the new size renders beside the scroll bar + expect(row.frame) == CGRect(x: 0, y: 0, width: 160 - thickness, height: 100) + } + + func test_render_heldByAnOverride_preparedNarrowerThanTheScrollBar_rendersAtZeroWidth() throws { + // given: a 10 pt wide view showing a legacy vertical scroll bar, which is wider than the view, holding a render pass + // prepared at that width, then widened to 30 pt and laid out while holding it + let view = RenderHoldingView(frame: CGRect(x: 0, y: 0, width: 10, height: 100)) + view.scrollIndicatorBehavior = .manual + view.scrollerStyle = .legacy + view.hasVerticalScroller = true + view.setContent { + ColorNode(.red) + .frame(width: .flexible, height: 100) + } + view.refresh(animated: false) + view.holdsRenderPass = true + view.refresh(animated: false) + view.frame.size.width = 30 + view.setNeedsLayout() + view.layoutIfNeeded() + + // when: the held pass is released + view.releaseRenderPass() + + // then: the earlier width minus the space the scroll bar takes now is below zero, so the held pass renders at zero + // width, and the document keeps its origin at zero + expect(view.test.lastRenderBounds?.size) == CGSize(width: 0, height: 100) + expect(view.contentContainerView.frame) == CGRect(x: 0, y: 0, width: 0, height: 100) + + // when: the run loop performs the follow-up layout + var isDrained = false + RunLoop.main.perform { isDrained = true } + expect(isDrained).toEventually(beTrue()) + + // then: the new size renders beside the scroll bar, with the document still at the origin + let thickness = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + expect(view.test.lastRenderBounds?.size) == CGSize(width: 30 - thickness, height: 100) + expect(view.contentContainerView.frame) == CGRect(x: 0, y: 0, width: 30 - thickness, height: 100) + } + #endif } /// A view whose `render()` override can hold a prepared render pass until released, as a subclass may. diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableTests.swift index c1bd136..1492494 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderableTests.swift @@ -123,13 +123,15 @@ class ComposeView_RenderableTests: XCTestCase { // when: refresh the view initially view.refresh(animated: false) - // then: expect the update context is correct, a non-animated refresh allows no animations + // then: expect the update context is correct, with the visible area as the render bounds, which the scrollers take + // space from on AppKit, and a non-animated refresh allows no animations + let visibleSize = view.visibleSize expect(willUpdateContext) == RenderableUpdateContext( updateType: .insert, oldFrame: CGRect(x: 0, y: 0, width: 100, height: 200), newFrame: CGRect(x: 0, y: 0, width: 100, height: 200), previousRenderBounds: nil, - renderBounds: CGRect(x: 0, y: 0, width: 100, height: 150), + renderBounds: CGRect(origin: .zero, size: visibleSize), animationTiming: nil, contentView: view, contentEvaluation: nil, @@ -140,7 +142,7 @@ class ComposeView_RenderableTests: XCTestCase { oldFrame: CGRect(x: 0, y: 0, width: 100, height: 200), newFrame: CGRect(x: 0, y: 0, width: 100, height: 200), previousRenderBounds: nil, - renderBounds: CGRect(x: 0, y: 0, width: 100, height: 150), + renderBounds: CGRect(origin: .zero, size: visibleSize), animationTiming: nil, contentView: view, contentEvaluation: nil, @@ -161,8 +163,8 @@ class ComposeView_RenderableTests: XCTestCase { updateType: .refresh, oldFrame: CGRect(x: 0, y: 0, width: 100, height: 200), newFrame: CGRect(x: 0, y: 0, width: 100, height: 200), - previousRenderBounds: CGRect(x: 0, y: 0, width: 100, height: 150), - renderBounds: CGRect(x: 0, y: 0, width: 100, height: 150), + previousRenderBounds: CGRect(origin: .zero, size: visibleSize), + renderBounds: CGRect(origin: .zero, size: visibleSize), animationTiming: nil, contentView: view, contentEvaluation: nil, @@ -172,8 +174,8 @@ class ComposeView_RenderableTests: XCTestCase { updateType: .refresh, oldFrame: CGRect(x: 0, y: 0, width: 100, height: 200), newFrame: CGRect(x: 0, y: 0, width: 100, height: 200), - previousRenderBounds: CGRect(x: 0, y: 0, width: 100, height: 150), - renderBounds: CGRect(x: 0, y: 0, width: 100, height: 150), + previousRenderBounds: CGRect(origin: .zero, size: visibleSize), + renderBounds: CGRect(origin: .zero, size: visibleSize), animationTiming: nil, contentView: view, contentEvaluation: nil, @@ -195,8 +197,8 @@ class ComposeView_RenderableTests: XCTestCase { updateType: .boundsChange, oldFrame: CGRect(x: 0, y: 0, width: 100, height: 200), newFrame: CGRect(x: 0, y: 0, width: 100, height: 200), - previousRenderBounds: CGRect(x: 0, y: 0, width: 100, height: 150), - renderBounds: CGRect(x: 0, y: 10, width: 100, height: 150), + previousRenderBounds: CGRect(origin: .zero, size: visibleSize), + renderBounds: CGRect(origin: CGPoint(x: 0, y: 10), size: visibleSize), animationTiming: nil, contentView: view, contentEvaluation: nil, @@ -206,8 +208,8 @@ class ComposeView_RenderableTests: XCTestCase { updateType: .boundsChange, oldFrame: CGRect(x: 0, y: 0, width: 100, height: 200), newFrame: CGRect(x: 0, y: 0, width: 100, height: 200), - previousRenderBounds: CGRect(x: 0, y: 0, width: 100, height: 150), - renderBounds: CGRect(x: 0, y: 10, width: 100, height: 150), + previousRenderBounds: CGRect(origin: .zero, size: visibleSize), + renderBounds: CGRect(origin: CGPoint(x: 0, y: 10), size: visibleSize), animationTiming: nil, contentView: view, contentEvaluation: nil, @@ -228,7 +230,7 @@ class ComposeView_RenderableTests: XCTestCase { updateType: .boundsChange, 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), + previousRenderBounds: CGRect(origin: CGPoint(x: 0, y: 10), size: visibleSize), renderBounds: CGRect(origin: view.contentOffset, size: CGSize(width: 200, height: 200)), animationTiming: nil, contentView: view, @@ -239,7 +241,7 @@ class ComposeView_RenderableTests: XCTestCase { updateType: .boundsChange, 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), + previousRenderBounds: CGRect(origin: CGPoint(x: 0, y: 10), size: visibleSize), renderBounds: CGRect(origin: view.contentOffset, size: CGSize(width: 200, height: 200)), animationTiming: nil, contentView: view, diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollBehaviorTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollBehaviorTests.swift index 9e32c1e..e2baa93 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollBehaviorTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollBehaviorTests.swift @@ -263,4 +263,45 @@ class ComposeView_ScrollBehaviorTests: XCTestCase { expect(contentView.isScrollEnabled) == true expect(contentView.clipsToBounds) == true } + + #if canImport(AppKit) + func test_scrollElasticity_followsTheContentSizeAndTheRenderSize() { + // given: a 100 × 100 view with overlay scroll bars that rendered content that fits it + var contentHeight: CGFloat = 50 + let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + contentView.setContent { + ColorNode(.red) + .frame(width: 50, height: contentHeight) + } + contentView.scrollerStyle = .overlay + contentView.refresh(animated: false) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 1e-3)) + expect(contentView.verticalScrollElasticity) == .none + + // when: the content gets taller than the view, the view refreshes, and the scroll elasticity updates + contentHeight = 300 + contentView.refresh(animated: false) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 1e-3)) + + // then: the content size changed, so the view bounces vertically + expect(contentView.contentSize) == CGSize(width: 100, height: 300) + expect(contentView.verticalScrollElasticity) == .allowed + + // when: the view scrolls, which changes neither the content size nor the render size + contentView.contentOffset = CGPoint(x: 0, y: 50) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 1e-3)) + + // then: the view still bounces vertically + expect(contentView.verticalScrollElasticity) == .allowed + + // when: the view gets as tall as the content, which changes the render size but keeps the content size + contentView.frame.size = CGSize(width: 100, height: 300) + contentView.layoutIfNeeded() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 1e-3)) + + // then: the content fits, so the view stops bouncing vertically + expect(contentView.contentSize) == CGSize(width: 100, height: 300) + expect(contentView.verticalScrollElasticity) == .none + } + #endif } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollIndicatorBehaviorTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollIndicatorBehaviorTests.swift index 30ac101..4d48fd7 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollIndicatorBehaviorTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+ScrollIndicatorBehaviorTests.swift @@ -326,13 +326,17 @@ class ComposeView_ScrollIndicatorBehaviorTests: XCTestCase { } func test_scrollIndicatorBehavior_auto_contentOverflowingByLessThanAPixel() { - // given: a view whose content is a tenth of a point wider than the view, less than a pixel + // given: a view whose content is a tenth of a point wider than the view, less than a pixel, with scroll bars that + // take no space, since a legacy horizontal scroll bar on macOS would take height the content needs let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) contentView.setContent { ColorNode(.red) .frame(width: 100.1, height: 100) } contentView.scrollIndicatorBehavior = .auto + #if canImport(AppKit) + contentView.scrollerStyle = .overlay + #endif // when: the view refreshes contentView.refresh(animated: false) @@ -341,4 +345,76 @@ class ComposeView_ScrollIndicatorBehaviorTests: XCTestCase { expect(contentView.showsHorizontalScrollIndicator) == true expect(contentView.showsVerticalScrollIndicator) == false } + + func test_scrollIndicatorBehavior_auto_flashesAShownScrollIndicatorAfterTheContentSizeIsSet() { + // given: a 100 × 100 view that shows its scroll indicators automatically, as overlay scroll bars on macOS, and + // rendered content that fits it + var contentHeight: CGFloat = 50 + let contentView = FlashRecordingComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + contentView.setContent { + ColorNode(.red) + .frame(width: .flexible, height: contentHeight) + } + contentView.scrollIndicatorBehavior = .auto + #if canImport(AppKit) + contentView.scrollerStyle = .overlay + #endif + contentView.refresh(animated: false) + expect(contentView.contentSizesWhenFlashing) == [] + + // when: the content gets taller than the view, and the view refreshes + contentHeight = 300 + contentView.refresh(animated: false) + + // then: the vertical scroll indicator flashes once, when the view already has the new content size, so the flash + // shows the new scroll range + expect(contentView.showsVerticalScrollIndicator) == true + expect(contentView.contentSizesWhenFlashing) == [CGSize(width: 100, height: 300)] + } + + func test_scrollIndicatorBehavior_changedToAuto_scrollDecidesTheScrollIndicators() { + // given: a 100 × 100 view that never shows its scroll indicators, as overlay scroll bars on macOS, and rendered + // content taller than it, then switched to showing them automatically + let contentView = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + contentView.setContent { + ColorNode(.red) + .frame(width: .flexible, height: 300) + } + contentView.scrollIndicatorBehavior = .never + #if canImport(AppKit) + contentView.scrollerStyle = .overlay + #endif + contentView.refresh(animated: false) + expect(contentView.showsVerticalScrollIndicator) == false + contentView.scrollIndicatorBehavior = .auto + + // when: the view scrolls + contentView.contentOffset = CGPoint(x: 0, y: 50) + contentView.layoutIfNeeded() + + // then: the scroll is the first render pass under the automatic behavior, so it decides the scroll indicators instead + // of keeping the hidden ones, and shows the vertical one + expect(contentView.showsVerticalScrollIndicator) == true + } +} + +/// A view that records its content size each time it flashes its scroll indicators. +private final class FlashRecordingComposeView: ComposeView { + + /// The content sizes the view had when it flashed its scroll indicators, in order. + var contentSizesWhenFlashing: [CGSize] = [] + + #if canImport(AppKit) + override func flashScrollers() { + contentSizesWhenFlashing.append(contentSize) + super.flashScrollers() + } + #endif + + #if canImport(UIKit) + override func flashScrollIndicators() { + contentSizesWhenFlashing.append(contentSize) + super.flashScrollIndicators() + } + #endif } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift index d1f21d0..65e7280 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift @@ -534,6 +534,33 @@ class ComposeViewTests: XCTestCase { expect(view.borderType) == .noBorder expect(view.contentView.frame) == CGRect(x: 0, y: 0, width: 100, height: 100) } + + func test_autohidesScrollers_staysOff() { + // given: a 100 × 100 compose view, and a handler that records the assertions + let view = ComposeView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + var assertionMessages: [String] = [] + ComposeUI.Assert.setTestAssertionFailureHandler { message, _, _, _ in + assertionMessages.append(message) + } + defer { + ComposeUI.Assert.resetTestAssertionFailureHandler() + } + + // when: auto-hiding is turned off + view.autohidesScrollers = false + + // then: nothing asserts, and it's off + expect(assertionMessages) == [] + expect(view.autohidesScrollers) == false + + // when: auto-hiding is turned on + view.autohidesScrollers = true + + // then: it asserts and stays off + expect(assertionMessages) == ["ComposeView doesn't support auto-hiding scrollers"] + expect(view.autohidesScrollers) == false + } #endif #if os(iOS)