From aa1e03c602da1459f86d1e2895fb822f525a14b8 Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Thu, 1 Oct 2026 02:05:12 -0700 Subject: [PATCH 1/2] [scroll] lock ComposeView's magnification, border and automatic content insets on macOS ComposeView's render bounds divided by the magnification and subtracted the border, and AppKit's automatic content insets wrote the window's title bar and toolbar overlap into the same `contentInsets` a caller sets, replacing the caller's value whenever the window's chrome changed. ComposeView never needed any of them, so they're now locked on macOS: `automaticallyAdjustsContentInsets` and `allowsMagnification` stay `false`, `magnification` stays 1, including through `setMagnification(_:centeredAt:)` and `magnify(toFit:)`, and `borderType` stays `.noBorder`. Changing them asserts and keeps the value, and the overrides are final. `renderBounds()` drops the border switch and the magnification division. iOS, tvOS and visionOS keep `contentInsetAdjustmentBehavior` adjustable, with `.never` as the default: UIKit keeps the caller's `contentInset` separate from the safe area, and layout and rendering work with automatic insets. Fitting content ignoring insets is tracked in #108. The document view and the clip view aren't guarded: removing or replacing them already fails at the next render pass, and a guard would only turn that into an assertion, at a small cost on every read. The render-bounds tests that existed only for borders are removed, and the ones that also covered scrollers or scaled bounds keep those parts. Fixes #103. --- CHANGELOG.md | 3 +- .../ComposeUI/ComposeView/ComposeView.swift | 91 ++++++++--- .../ComposeView+RenderBoundsTests.swift | 91 ++--------- .../ComposeView/ComposeViewTests.swift | 144 +++++++++++++++++- 4 files changed, 230 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a065c..1049002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +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 inside the border whether or not scroll bars show, so tiling that takes space from the clip view would cover the content. Place accessory views outside the `ComposeView` instead. +- `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. - 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` stays 1, including through `setMagnification(_:centeredAt:)` and `magnify(toFit:)`, 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.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift index 8167e80..61fc9bb 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift @@ -710,16 +710,79 @@ open class ComposeView: BaseScrollView { } #endif + #if canImport(AppKit) + // MARK: - Tiling - #if canImport(AppKit) /// Tiles the clip view and the scrollers. /// - /// It's final because the content lays out for the view's bounds inside the border, so space that custom tiling takes - /// from the clip view would cover the content. + /// 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 super.tile() } + + // MARK: - Locked Scroll View Settings + + /// Always `false`, so the content insets stay the same under the window's title bar and toolbar. + /// + /// Setting it to `true` asserts and keeps it `false`. + override public final var automaticallyAdjustsContentInsets: Bool { + get { + super.automaticallyAdjustsContentInsets + } + set { + ComposeUI.assert(!newValue, "ComposeView doesn't support adjusting the content insets automatically") + super.automaticallyAdjustsContentInsets = false + } + } + + /// Always `false`, since `ComposeView` doesn't support magnification. + /// + /// Setting it to `true` asserts and keeps it `false`. + override public final var allowsMagnification: Bool { + get { + super.allowsMagnification + } + set { + ComposeUI.assert(!newValue, "ComposeView doesn't support magnification") + } + } + + /// Always 1, since `ComposeView` doesn't support magnification. + /// + /// Setting another value asserts and keeps 1. + override public final var magnification: CGFloat { + get { + super.magnification + } + set { + ComposeUI.assert(newValue == 1, "ComposeView doesn't support magnification") + } + } + + /// Keeps the magnification at 1, since `ComposeView` doesn't support magnification. Another magnification asserts. + override public final func setMagnification(_ magnification: CGFloat, centeredAt point: CGPoint) { + ComposeUI.assert(magnification == 1, "ComposeView doesn't support magnification") + } + + /// Keeps the magnification at 1 and asserts, since `ComposeView` doesn't support magnification. + override public final func magnify(toFit rect: CGRect) { + ComposeUI.assertFailure("ComposeView doesn't support magnification") + } + + /// Always `.noBorder`, since the content lays out for the view's whole bounds. + /// + /// Setting another border type asserts and keeps `.noBorder`. + override public final var borderType: NSBorderType { + get { + super.borderType + } + set { + ComposeUI.assert(newValue == .noBorder, "ComposeView doesn't support borders") + } + } + #endif // MARK: - Render @@ -1549,19 +1612,9 @@ 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 inside the border, - // where AppKit tiles the clip view without scrollers. it then depends only on the bounds, the border, and the - // magnification, so showing or hiding a scroller never changes the layout. - let borderWidth: CGFloat - switch borderType { - case .noBorder: - borderWidth = 0 - case .grooveBorder: - // `NSScrollView.contentSize(forFrameSize:...)` assumes 1 pt here, but AppKit tiles a 2 pt groove - borderWidth = 2 - default: - borderWidth = 1 - } + // 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. // AppKit rounds the clip view's size, not its edges, to whole backing pixels, through any bounds scaling, so the size // rounds the same way to match the area the view shows. each dimension rounds with the pixel scale along its own axis, // the length of a converted unit vector, since converting the size itself would mix the dimensions under rotation. @@ -1569,9 +1622,9 @@ open class ComposeView: BaseScrollView { let unitY = convertToBacking(CGSize(width: 0, height: 1)) let pixelsPerUnitX = hypot(unitX.width, unitX.height) let pixelsPerUnitY = hypot(unitY.width, unitY.height) - let width = (max(self.bounds.width - 2 * borderWidth, 0) * pixelsPerUnitX).rounded() / pixelsPerUnitX - let height = (max(self.bounds.height - 2 * borderWidth, 0) * pixelsPerUnitY).rounded() / pixelsPerUnitY - return CGRect(origin: contentOffset, size: CGSize(width: width / magnification, height: height / magnification)) + let width = (self.bounds.width * pixelsPerUnitX).rounded() / pixelsPerUnitX + let height = (self.bounds.height * pixelsPerUnitY).rounded() / pixelsPerUnitY + return CGRect(origin: contentOffset, size: CGSize(width: width, height: height)) #endif #if canImport(UIKit) diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift index ef39862..4c1a33c 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift @@ -327,27 +327,23 @@ class ComposeView_RenderBoundsTests: XCTestCase { expect(renderBounds) == [CGRect(x: 0, y: view.maxOffsetY, width: 100, height: 100)] } - func test_renderBounds_legacyScrollers_sizeFollowsTheFrameBorderAndMagnification() { - // given: a view with a line border, content insets, and a magnification of 2, showing both legacy scrollers + func test_renderBounds_legacyScrollers_sizeFollowsTheFrame() { + // given: a 120 × 80 view with content insets, showing both legacy scrollers let view = ComposeView { LayerNode().frame(width: 300, height: 300) } - view.frame = CGRect(x: 0, y: 0, width: 122, height: 82) + view.frame = CGRect(x: 0, y: 0, width: 120, height: 80) useLegacyScrollers(view) - view.borderType = .lineBorder view.contentInsets = NSEdgeInsets(top: 10, left: 5, bottom: 7, right: 3) - view.allowsMagnification = true - view.magnification = 2 // 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 inside the - // 1 pt border, in the document's magnified coordinates + // then: the scrollers show over the clip view with the insets, and the content lays out for the frame expect(view.hasHorizontalScroller) == true expect(view.hasVerticalScroller) == true expect(view.contentView.frame.size) == CGSize(width: 120, height: 80) - expect(view.test.lastRenderBounds?.size) == CGSize(width: 60, height: 40) + expect(view.test.lastRenderBounds?.size) == CGSize(width: 120, height: 80) // when: the insets are removed, so the scrollers take space from the clip view, and the view refreshes view.contentInsets = NSEdgeInsetsZero @@ -356,53 +352,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { // then: the size stays 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: 60, height: 40) - } - - func test_renderBounds_borderTypes_matchTheClipViewWithoutScrollers() { - for borderType in [NSBorderType.noBorder, .lineBorder, .bezelBorder, .grooveBorder] { - // given: a 240 × 180 view with the border and no scrollers - let view = ComposeView { - LayerNode().frame(width: 10, height: 10) - } - view.frame = CGRect(x: 0, y: 0, width: 240, height: 180) - view.borderType = borderType - - // when: the view refreshes - view.refresh(animated: false) - - // then: the content lays out for the area AppKit tiles the clip view in - expect(view.test.lastRenderBounds?.size) == view.contentView.bounds.size - } - } - - func test_renderBounds_grooveBorder_contentOverflowingTheTiledBorderScrolls() { - // given: a 240 × 180 view with a groove border, which AppKit tiles 2 pt wide on each side, leaving 236 × 176 without - // scrollers, showing 237 × 177 content that overflows that area, though not the 238 × 178 AppKit calculates for the - // frame - let view = ComposeView { - LayerNode().frame(width: 237, height: 177) - } - view.frame = CGRect(x: 0, y: 0, width: 240, height: 180) - useLegacyScrollers(view) - view.borderType = .grooveBorder - - // when: the view refreshes - view.refresh(animated: false) - - // then: the content lays out for the area inside the tiled border, so it scrolls, with both scrollers shown - expect(view.test.lastRenderBounds?.size) == CGSize(width: 236, height: 176) - expect(view.isScrollEnabled) == true - expect(view.hasHorizontalScroller) == true - expect(view.hasVerticalScroller) == true - - // when: the scrollers hide - view.scrollIndicatorBehavior = .never - view.refresh(animated: false) - - // then: the size stays, and it's the area the view shows - expect(view.test.lastRenderBounds?.size) == CGSize(width: 236, height: 176) - expect(view.contentView.bounds.size) == CGSize(width: 236, height: 176) + expect(view.test.lastRenderBounds?.size) == CGSize(width: 120, height: 80) } func test_renderBounds_scaledBounds_rendersTheScaledViewport() { @@ -544,18 +494,18 @@ class ComposeView_RenderBoundsTests: XCTestCase { expect(view.hasHorizontalScroller) == false } - func test_renderBounds_scaledBoundsWithBorder_matchesTheClipView() { - for (borderType, boundsLength, contentLength) in [(NSBorderType.lineBorder, CGFloat(150), CGFloat(148)), (.grooveBorder, 300, 296)] { - // given: a 100 × 100 view in a window, with the border and scaled bounds, showing content as large as the area - // inside the border before AppKit rounds it to the pixels of the scaled bounds + func test_renderBounds_scaledBounds_fractionalViewSize_matchesTheClipView() { + for (frameLength, contentLength) in [(CGFloat(99.2), CGFloat(148.8)), (99.8, 149.8)] { + // given: a view in a window with a fractional frame and bounds 1.5 times as large, showing content between the + // bounds' size and the size AppKit rounds the clip view to, at both 1x and 2x: down to 148.5 units for a 99.2 pt + // frame, and up to 150 for a 99.8 pt one let window = TestWindow() let view = ComposeView { LayerNode().frame(width: contentLength, height: contentLength) } - view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) - view.borderType = borderType + view.frame = CGRect(x: 0, y: 0, width: frameLength, height: frameLength) window.contentView().addSubview(view) - view.setBoundsSize(CGSize(width: boundsLength, height: boundsLength)) + view.setBoundsSize(CGSize(width: frameLength * 1.5, height: frameLength * 1.5)) // when: the view refreshes view.refresh(animated: false) @@ -567,21 +517,6 @@ class ComposeView_RenderBoundsTests: XCTestCase { } } - func test_renderBounds_viewSmallerThanItsBorder_laysOutForAnEmptySize() { - // given: a 3 × 3 view with a groove border, which is 2 pt wide on each side - let view = ComposeView { - LayerNode().frame(width: 10, height: 10) - } - view.frame = CGRect(x: 0, y: 0, width: 3, height: 3) - view.borderType = .grooveBorder - - // when: the view refreshes - view.refresh(animated: false) - - // then: the content lays out for an empty size rather than a negative one - expect(view.test.lastRenderBounds?.size) == .zero - } - func test_renderBounds_rotatedView_laysOutForItsOwnSize() { // given: a 200 × 100 view nested in another compose view in a window, rotated 30°, showing twenty 10 pt wide columns // as tall as the view diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift index 7598472..82be30a 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift @@ -357,7 +357,7 @@ class ComposeViewTests: XCTestCase { expect(nested.frame) == CGRect(x: 0, y: 0, width: 180, height: 300) } - // MARK: - + // MARK: - Scroll View Settings func test_contentInsetAdjustmentBehavior() { // then: automatic content inset adjustment is disabled @@ -368,4 +368,146 @@ class ComposeViewTests: XCTestCase { expect(contentView.contentInsetAdjustmentBehavior) == .never #endif } + + #if canImport(AppKit) + func test_automaticallyAdjustsContentInsets_staysOff() { + // given: a compose view filling a window whose title bar and toolbar overlap it, and a handler that records the + // assertions + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.toolbar = NSToolbar(identifier: "ComposeViewTests") + let view = ComposeView() + window.contentView = view + + var assertionMessages: [String] = [] + ComposeUI.Assert.setTestAssertionFailureHandler { message, _, _, _ in + assertionMessages.append(message) + } + defer { + ComposeUI.Assert.resetTestAssertionFailureHandler() + } + + // when: the automatic adjustment is turned off + view.automaticallyAdjustsContentInsets = false + + // then: nothing asserts + expect(assertionMessages) == [] + + // when: the automatic adjustment is turned on, and the window lays out + view.automaticallyAdjustsContentInsets = true + window.layoutIfNeeded() + + // then: it asserts and stays off, so the content insets leave out the overlap + expect(assertionMessages) == ["ComposeView doesn't support adjusting the content insets automatically"] + expect(view.automaticallyAdjustsContentInsets) == false + expect(view.bounds.height - window.contentLayoutRect.height) > 0 + expect(view.contentInset.top) == 0 + } + + func test_magnification_staysOne() { + // 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: magnification is disallowed and set to 1 + view.allowsMagnification = false + view.magnification = 1 + view.setMagnification(1, centeredAt: CGPoint(x: 50, y: 50)) + + // then: nothing asserts + expect(assertionMessages) == [] + + // when: magnification is allowed + view.allowsMagnification = true + + // then: it asserts and stays disallowed + expect(assertionMessages) == ["ComposeView doesn't support magnification"] + expect(view.allowsMagnification) == false + + // when: the view is magnified through each of AppKit's magnifying APIs + view.magnification = 2 + view.setMagnification(2, centeredAt: CGPoint(x: 50, y: 50)) + view.magnify(toFit: CGRect(x: 0, y: 0, width: 400, height: 400)) + + // then: each asserts, and the magnification stays 1, so the visible area keeps the view's size + expect(assertionMessages) == Array(repeating: "ComposeView doesn't support magnification", count: 4) + expect(view.magnification) == 1 + expect(view.visibleSize) == CGSize(width: 100, height: 100) + } + + func test_borderType_staysNoBorder() { + // 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: the view is set to have no border + view.borderType = .noBorder + + // then: nothing asserts + expect(assertionMessages) == [] + + // when: the view is given each border, and it tiles + for borderType in [NSBorderType.lineBorder, .bezelBorder, .grooveBorder] { + view.borderType = borderType + } + view.tile() + + // then: each asserts, and the view stays borderless, so the clip view fills it + expect(assertionMessages) == Array(repeating: "ComposeView doesn't support borders", count: 3) + expect(view.borderType) == .noBorder + expect(view.contentView.frame) == CGRect(x: 0, y: 0, width: 100, height: 100) + } + #endif + + #if os(iOS) + func test_contentInsetAdjustmentBehavior_automatic_rendersTheVisibleRows() { + // given: a compose view showing fifty 20 pt rows, set to adjust its content insets automatically, as the root view + // of a view controller in a navigation controller + let view = ComposeView { + VStack { + for _ in 0 ..< 50 { + LayerNode().frame(width: .flexible, height: 20) + } + } + } + view.contentInsetAdjustmentBehavior = .automatic + let viewController = UIViewController() + viewController.view = view + let window = TestWindow() + window.rootViewController = UINavigationController(rootViewController: viewController) + + // when: the window shows and lays out + window.makeKeyAndVisible() + window.layoutIfNeeded() + view.layoutIfNeeded() + + // then: UIKit insets the content by the navigation bar, and shows its top right below the bar + expect(view.contentInsetAdjustmentBehavior) == .automatic + expect(view.adjustedContentInset.top) > 0 + expect(view.contentOffset.y) == -view.adjustedContentInset.top + + // then: the rendered rows are the ones in the visible area + let rowFrames = (view.layer.sublayers ?? []).map(\.frame).filter { $0.size == CGSize(width: view.bounds.width, height: 20) } + let visibleRowCount = Int((view.bounds.maxY / 20).rounded(.up)) + expect(rowFrames.sorted { $0.minY < $1.minY }) == (0 ..< visibleRowCount).map { CGRect(x: 0, y: CGFloat($0) * 20, width: view.bounds.width, height: 20) } + } + #endif } From 9690b0f66b536643649002399a3778d5ccc1da46 Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Thu, 1 Oct 2026 02:34:08 -0700 Subject: [PATCH 2/2] [scroll] lock ComposeView's magnification range at 1, so the animator can't magnify it either MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Through an `NSScrollView` reference, the animator's `magnify(toFit:)` and `setMagnification(_:centeredAt:)` change the magnification without calling ComposeView's overrides, so a 100×100 view ended at a magnification of 0.25, showing a 400×400 area while its content laid out for 100×100. AppKit clamps every way of magnifying to `minMagnification` and `maxMagnification`, so ComposeView now sets both to 1 and locks them like the magnification: changing either asserts and keeps 1. AppKit doesn't read either while scrolling. The animator's `magnify(toFit:)` still scrolls to center the rect, which lays out and renders like any other scroll. --- CHANGELOG.md | 2 +- .../ComposeUI/ComposeView/ComposeView.swift | 30 +++++++++ .../ComposeView/ComposeViewTests.swift | 63 ++++++++++++++++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1049002..f26541b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ - `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. - 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` stays 1, including through `setMagnification(_:centeredAt:)` and `magnify(toFit:)`, 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 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`. ### Changes diff --git a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift index 61fc9bb..0c33d0a 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift @@ -242,6 +242,10 @@ open class ComposeView: BaseScrollView { #if canImport(AppKit) drawsBackground = false // make the view transparent automaticallyAdjustsContentInsets = false + // AppKit clamps every way of magnifying to this range, including the animator's `magnify(toFit:)` and + // `setMagnification(_:centeredAt:)`, which change the magnification without calling the overrides + minMagnification = 1 + maxMagnification = 1 // set the scroll indicators to be shown by default // this is to make the scroll indicators are visible immediately when scrolling for the first time @@ -771,6 +775,32 @@ open class ComposeView: BaseScrollView { ComposeUI.assertFailure("ComposeView doesn't support magnification") } + /// Always 1, since `ComposeView` doesn't support magnification. + /// + /// Setting another value asserts and keeps 1. + override public final var minMagnification: CGFloat { + get { + super.minMagnification + } + set { + ComposeUI.assert(newValue == 1, "ComposeView doesn't support magnification") + super.minMagnification = 1 + } + } + + /// Always 1, since `ComposeView` doesn't support magnification. + /// + /// Setting another value asserts and keeps 1. + override public final var maxMagnification: CGFloat { + get { + super.maxMagnification + } + set { + ComposeUI.assert(newValue == 1, "ComposeView doesn't support magnification") + super.maxMagnification = 1 + } + } + /// Always `.noBorder`, since the content lays out for the view's whole bounds. /// /// Setting another border type asserts and keeps `.noBorder`. diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift index 82be30a..d1f21d0 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeViewTests.swift @@ -420,10 +420,12 @@ class ComposeViewTests: XCTestCase { ComposeUI.Assert.resetTestAssertionFailureHandler() } - // when: magnification is disallowed and set to 1 + // when: magnification is disallowed, and the magnification and its range are set to 1 view.allowsMagnification = false view.magnification = 1 view.setMagnification(1, centeredAt: CGPoint(x: 50, y: 50)) + view.minMagnification = 1 + view.maxMagnification = 1 // then: nothing asserts expect(assertionMessages) == [] @@ -435,17 +437,74 @@ class ComposeViewTests: XCTestCase { expect(assertionMessages) == ["ComposeView doesn't support magnification"] expect(view.allowsMagnification) == false + // when: the magnification range is widened + view.minMagnification = 0.25 + view.maxMagnification = 4 + + // then: each asserts, and the range stays at 1 + expect(assertionMessages) == Array(repeating: "ComposeView doesn't support magnification", count: 3) + expect(view.minMagnification) == 1 + expect(view.maxMagnification) == 1 + // when: the view is magnified through each of AppKit's magnifying APIs view.magnification = 2 view.setMagnification(2, centeredAt: CGPoint(x: 50, y: 50)) view.magnify(toFit: CGRect(x: 0, y: 0, width: 400, height: 400)) // then: each asserts, and the magnification stays 1, so the visible area keeps the view's size - expect(assertionMessages) == Array(repeating: "ComposeView doesn't support magnification", count: 4) + expect(assertionMessages) == Array(repeating: "ComposeView doesn't support magnification", count: 6) expect(view.magnification) == 1 expect(view.visibleSize) == CGSize(width: 100, height: 100) } + func test_magnification_animator_staysOne() { + // given: a 100 × 100 compose view in a window, showing fifty 10 pt rows, referenced as an `NSScrollView`, where the + // animator's magnifying methods don't call the view's overrides + let window = TestWindow() + let view = ComposeView { + VStack { + for _ in 0 ..< 50 { + LayerNode().frame(width: .flexible, height: 10) + } + } + } + view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + window.contentView().addSubview(view) + view.refresh(animated: false) + let scrollView: NSScrollView = view + + ComposeUI.Assert.setTestAssertionFailureHandler { _, _, _, _ in } + defer { + ComposeUI.Assert.resetTestAssertionFailureHandler() + } + + let magnifyingCalls: [(NSScrollView) -> Void] = [ + { $0.animator().setMagnification(0.25, centeredAt: CGPoint(x: 50, y: 50)) }, + { $0.animator().magnify(toFit: CGRect(x: 0, y: 0, width: 400, height: 400)) }, + ] + for magnify in magnifyingCalls { + // when: the view is magnified through the animator, and the animation finishes + var isFinished = false + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.05 + magnify(scrollView) + } completionHandler: { + isFinished = true + } + expect(isFinished).toEventually(beTrue()) + view.refresh(animated: false) + + // then: the magnification stays 1, so the clip view keeps the view's size, and the rendered rows are the visible + // ones at their full width + expect(view.magnification) == 1 + expect(view.contentView.bounds.size) == CGSize(width: 100, height: 100) + let visibleBounds = view.contentView.bounds + let rowFrames = (view.documentView?.layer?.sublayers ?? []).map(\.frame).filter { $0.height == 10 } + let visibleRows = Int((visibleBounds.minY / 10).rounded(.down)) ..< Int((visibleBounds.maxY / 10).rounded(.up)) + expect(rowFrames.sorted { $0.minY < $1.minY }) == visibleRows.map { CGRect(x: 0, y: CGFloat($0) * 10, width: 100, height: 10) } + } + } + func test_borderType_staysNoBorder() { // 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))