diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a065c..f26541b 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`, `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 8167e80..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 @@ -710,16 +714,105 @@ 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 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`. + override public final var borderType: NSBorderType { + get { + super.borderType + } + set { + ComposeUI.assert(newValue == .noBorder, "ComposeView doesn't support borders") + } + } + #endif // MARK: - Render @@ -1549,19 +1642,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 +1652,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..d1f21d0 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,205 @@ 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 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) == [] + + // 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 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: 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)) + + 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 }