From a5a81ff0959dbe85447adb7ac127c7f085813967 Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Thu, 1 Oct 2026 12:54:01 -0700 Subject: [PATCH 1/3] [scroll] use the exact visible size on macOS, instead of the clip view's size rounded to whole pixels AppKit rounds the clip view's size to whole pixels, so a 99.2 pt scroll view reported a visible size of 99 and its content laid out for 99, while UIKit uses 99.2. The clip view now records the size AppKit asks for before rounding it, so visibleSize, the layout size, contentSize and the scroll range match UIKit's. AppKit still snaps where scrolling comes to rest to the window's pixels, so the edge checks count an offset within a pixel of an edge as at the edge. This also fixes trackpad scroll chaining for nested scroll views at fractional sizes. Fixes #102. --- .../ComposeUI/ComposeView/ComposeView.swift | 11 +-- .../ScrollView/ScrollView+Scrolling.swift | 12 ++- .../CrossPlatform/ScrollView/ScrollView.swift | 39 ++++++-- .../ComposeView+RenderBoundsTests.swift | 60 ++++++++----- .../ScrollView+ScrollingTests.swift | 55 ++++++++++++ .../ScrollView/ScrollViewTests.swift | 90 +++++++++++++++++++ 6 files changed, 226 insertions(+), 41 deletions(-) diff --git a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift index 0c33d0a..46da2e1 100644 --- a/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift +++ b/ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift @@ -1645,16 +1645,7 @@ open class ComposeView: BaseScrollView { // 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. - let unitX = convertToBacking(CGSize(width: 1, height: 0)) - let unitY = convertToBacking(CGSize(width: 0, height: 1)) - let pixelsPerUnitX = hypot(unitX.width, unitX.height) - let pixelsPerUnitY = hypot(unitY.width, unitY.height) - 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)) + return CGRect(origin: contentOffset, size: self.bounds.size) #endif #if canImport(UIKit) diff --git a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift index dfd4cd9..ca29b0e 100644 --- a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift +++ b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift @@ -58,24 +58,28 @@ public extension ScrollView { contentSize.height - visibleSize.height + adjustedContentInset.bottom } + // Scrolling comes to rest on whole pixels, since AppKit aligns the content to the window's pixels and UIKit snaps the + // offset to them, which can leave it up to about a pixel from an edge computed from the exact sizes. So the + // properties below count an offset within a pixel of an edge as at the edge. + /// Whether the scroll view can scroll to the left. var canScrollToLeft: Bool { - contentOffset.x > minOffsetX + contentOffset.x > minOffsetX + pixelLength } /// Whether the scroll view can scroll to the right. var canScrollToRight: Bool { - contentOffset.x < maxOffsetX + contentOffset.x < maxOffsetX - pixelLength } /// Whether the scroll view can scroll to the top. var canScrollToTop: Bool { - contentOffset.y > minOffsetY + contentOffset.y > minOffsetY + pixelLength } /// Whether the scroll view can scroll to the bottom. var canScrollToBottom: Bool { - contentOffset.y < maxOffsetY + contentOffset.y < maxOffsetY - pixelLength } #if canImport(UIKit) diff --git a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift index 5bb3da2..22e6950 100644 --- a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift +++ b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift @@ -85,8 +85,13 @@ open class ScrollView: NSScrollView { } /// The size of the visible area in content coordinates, like `UIScrollView`'s `visibleSize`. + /// + /// It's exact, while the clip view rounds its own size to whole pixels. public var visibleSize: CGSize { - contentView.bounds.size + guard let size = (contentView as? ScrollClipView)?.unroundedSize else { + return contentView.bounds.size + } + return CGSize(width: size.width / magnification, height: size.height / magnification) } override public init(frame: CGRect) { @@ -94,6 +99,7 @@ open class ScrollView: NSScrollView { updateCommonSettings() + contentView = ScrollClipView() documentView = BaseView() startObservingBoundsChange() @@ -265,10 +271,12 @@ open class ScrollView: NSScrollView { } private func updateScrollElasticity() { + // compare the document with the exact visible size: the clip view rounds its size to whole pixels, so content as + // large as the view would otherwise bounce by the leftover fraction if alwaysBounceHorizontal { horizontalScrollElasticity = .allowed } else { - if contentContainerView.frame.width.extends(beyond: super.contentSize.width) { + if contentContainerView.frame.width.extends(beyond: visibleSize.width) { horizontalScrollElasticity = .allowed } else { horizontalScrollElasticity = .none @@ -278,7 +286,7 @@ open class ScrollView: NSScrollView { if alwaysBounceVertical { verticalScrollElasticity = .allowed } else { - if contentContainerView.frame.height.extends(beyond: super.contentSize.height) { + if contentContainerView.frame.height.extends(beyond: visibleSize.height) { verticalScrollElasticity = .allowed } else { verticalScrollElasticity = .none @@ -380,15 +388,18 @@ private final class ScrollSession { private extension ScrollView { - /// Whether the content offset is outside the scrollable range along either axis. + /// Whether the content offset is outside the scrollable range along either axis, by more than a pixel. /// /// Along an axis where the content is smaller than the visible size, the maximum offset is below the minimum, and the /// scrollable range is the minimum offset alone. var isContentOffsetOutsideScrollableRange: Bool { let offset = contentOffset + // AppKit aligns where scrolling comes to rest to the window's pixels, which can leave the offset up to about a + // pixel past an end of the exact range, so an offset within a pixel of the range counts as inside it + let pixel = pixelLength let minX = minOffsetX let minY = minOffsetY - return offset.x < minX || offset.x > max(minX, maxOffsetX) || offset.y < minY || offset.y > max(minY, maxOffsetY) + return offset.x < minX - pixel || offset.x > max(minX, maxOffsetX) + pixel || offset.y < minY - pixel || offset.y > max(minY, maxOffsetY) + pixel } /// Whether the view has a parent scroll view that satisfies the condition. @@ -404,6 +415,19 @@ private extension ScrollView { } } +/// A clip view that keeps the size AppKit tiles it to, before it rounds that size to whole pixels. +private final class ScrollClipView: NSClipView { + + /// The size AppKit last set, before the clip view rounded it. + private(set) var unroundedSize: CGSize? + + override func setFrameSize(_ newSize: NSSize) { + unroundedSize = newSize + + super.setFrameSize(newSize) + } +} + #endif // MARK: - UIKit @@ -426,4 +450,9 @@ extension ScrollView { return self #endif } + + /// The length of a pixel, in points. + var pixelLength: CGFloat { + 1 / windowScaleFactor + } } diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift index 4c1a33c..8b4bcdf 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderBoundsTests.swift @@ -445,7 +445,7 @@ class ComposeView_RenderBoundsTests: XCTestCase { expect(renderedFrames(in: view)) == (0 ..< 5).map { CGRect(x: 0, y: 65 + CGFloat($0) * 10, width: 400, height: 10) } } - func test_renderBounds_fractionalViewSize_roundsDownWithTheClipView() { + func test_renderBounds_fractionalViewSize_clipViewRoundsDown_staysExact() { // given: a 99.2 × 99.2 view in a window, with overlay scrollers, showing flexible rows that overflow vertically let window = TestWindow() let view = ComposeView { @@ -460,19 +460,29 @@ class ComposeView_RenderBoundsTests: XCTestCase { view.scrollerStyle = .overlay window.contentView().addSubview(view) - // when: the view refreshes + // when: the view refreshes, and its scroll elasticity updates view.refresh(animated: false) - - // then: the rows lay out for the size AppKit rounds the clip view down to, so the content is no wider than the - // visible area, and only the vertical scroller shows - expect(view.visibleSize) == CGSize(width: 99, height: 99) - expect(view.test.lastRenderBounds?.size) == CGSize(width: 99, height: 99) - expect(view.contentSize) == CGSize(width: 99, height: 400) + view.invalidateScrollElasticity() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 1e-3)) + + // then: the rows lay out for the exact size, while AppKit rounds the clip view down to whole pixels, at both 1x and + // 2x + expect(view.visibleSize) == CGSize(width: 99.2, height: 99.2) + expect(view.test.lastRenderBounds?.size) == CGSize(width: 99.2, height: 99.2) + expect(view.contentView.frame.size) == CGSize(width: 99, height: 99) + + // then: the document keeps the exact width, which fits the exact visible size even though it's wider than the clip + // view, so the maximum horizontal offset is 0, the view doesn't bounce sideways, and only the vertical scroller shows + expect(view.contentSize) == CGSize(width: 99.2, height: 400) + expect(view.maxOffsetX) == 0 + expect(view.canScrollToRight) == false + expect(view.horizontalScrollElasticity) == .none + expect(view.verticalScrollElasticity) == .allowed expect(view.hasVerticalScroller) == true expect(view.hasHorizontalScroller) == false } - func test_renderBounds_fractionalViewSize_roundsUpWithTheClipView() { + func test_renderBounds_fractionalViewSize_clipViewRoundsUp_staysExact() { // given: a 99.8 × 99.8 view at (0.4, 0.4) in a window, with legacy scrollers, showing 99.7 × 99.7 content let window = TestWindow() let view = ComposeView { @@ -485,20 +495,22 @@ class ComposeView_RenderBoundsTests: XCTestCase { // when: the view refreshes view.refresh(animated: false) - // then: the content lays out for the size AppKit rounds the clip view up to, which it fits, so the view neither - // scrolls nor shows scrollers - expectSize(view.visibleSize, approximatelyEquals: CGSize(width: 100, height: 100)) - expectSize(view.test.lastRenderBounds?.size, approximatelyEquals: CGSize(width: 100, height: 100)) + // then: the content lays out for the exact size, which it fits, while AppKit rounds the clip view up to whole pixels, + // and the document keeps the exact size, so the view neither scrolls nor shows scrollers + expect(view.visibleSize) == CGSize(width: 99.8, height: 99.8) + expect(view.test.lastRenderBounds?.size) == CGSize(width: 99.8, height: 99.8) + expectSize(view.contentView.frame.size, approximatelyEquals: CGSize(width: 100, height: 100)) + expect(view.contentSize) == CGSize(width: 99.8, height: 99.8) expect(view.isScrollEnabled) == false expect(view.hasVerticalScroller) == false expect(view.hasHorizontalScroller) == false } - 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 + func test_renderBounds_scaledBounds_fractionalViewSize_fittingContentDoesNotScroll() { + for (frameLength, contentLength) in [(CGFloat(99.2), CGFloat(148.6)), (99.8, 149.6)] { + // given: a view in a window with a fractional frame and bounds 1.5 times as large, so AppKit rounds the clip view + // to whole pixels through the scaling, 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, showing content that fits the exact bounds, even where it's larger than the rounded clip view let window = TestWindow() let view = ComposeView { LayerNode().frame(width: contentLength, height: contentLength) @@ -510,10 +522,14 @@ class ComposeView_RenderBoundsTests: XCTestCase { // when: the view refreshes view.refresh(animated: false) - // then: the content lays out for the clip view's size, so the view scrolls exactly when the content doesn't fit it - let clipSize = view.contentView.frame.size - expectSize(view.test.lastRenderBounds?.size, approximatelyEquals: clipSize) - expect(view.isScrollEnabled) == (contentLength > clipSize.width) + // then: the content lays out for the exact bounds, and the document keeps the exact size, so the view doesn't + // scroll + let boundsLength = frameLength * 1.5 + expectSize(view.test.lastRenderBounds?.size, approximatelyEquals: CGSize(width: boundsLength, height: boundsLength)) + expectSize(view.contentSize, approximatelyEquals: CGSize(width: boundsLength, height: boundsLength)) + expect(view.isScrollEnabled) == false + expect(view.maxOffsetX).to(beApproximatelyEqual(to: 0, within: 1e-9)) + expect(view.maxOffsetY).to(beApproximatelyEqual(to: 0, within: 1e-9)) } } diff --git a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollView+ScrollingTests.swift b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollView+ScrollingTests.swift index f14cb9f..34a4716 100644 --- a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollView+ScrollingTests.swift +++ b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollView+ScrollingTests.swift @@ -93,4 +93,59 @@ class ScrollView_ScrollingTests: XCTestCase { expect(scrollView.canScrollToTop) == true expect(scrollView.canScrollToBottom) == false } + + func test_canScroll_withinAPixelOfAnEdge() { + // given: a scroll view with content size and insets, automatic inset adjustment disabled + let scrollView = ScrollView() + scrollView.frame = CGRect(x: 0, y: 0, width: 100, height: 100) + scrollView.contentSize = CGSize(width: 180, height: 220) + scrollView.contentInset = EdgeInsets(top: 8, left: 6, bottom: 4, right: 2) + #if canImport(AppKit) + scrollView.automaticallyAdjustsContentInsets = false + #endif + #if canImport(UIKit) + scrollView.contentInsetAdjustmentBehavior = .never + #endif + + // when: the offset is within a pixel of the minimum offset, at any display scale + setExactOffset(CGPoint(x: scrollView.minOffsetX + 0.3, y: scrollView.minOffsetY + 0.3), of: scrollView) + + // then: it counts as at the left and top edges + expect(scrollView.canScrollToLeft) == false + expect(scrollView.canScrollToTop) == false + + // when: the offset is within a pixel of the maximum offset + setExactOffset(CGPoint(x: scrollView.maxOffsetX - 0.3, y: scrollView.maxOffsetY - 0.3), of: scrollView) + + // then: it counts as at the right and bottom edges + expect(scrollView.canScrollToRight) == false + expect(scrollView.canScrollToBottom) == false + + // when: the offset is more than a pixel from the minimum offset, at any display scale + setExactOffset(CGPoint(x: scrollView.minOffsetX + 1.5, y: scrollView.minOffsetY + 1.5), of: scrollView) + + // then: it can scroll to the left and to the top + expect(scrollView.canScrollToLeft) == true + expect(scrollView.canScrollToTop) == true + + // when: the offset is more than a pixel from the maximum offset + setExactOffset(CGPoint(x: scrollView.maxOffsetX - 1.5, y: scrollView.maxOffsetY - 1.5), of: scrollView) + + // then: it can scroll to the right and to the bottom + expect(scrollView.canScrollToRight) == true + expect(scrollView.canScrollToBottom) == true + } + + // MARK: - Helpers + + /// Sets the content offset exactly, since UIKit snaps a set `contentOffset` to whole pixels, while assigning + /// `bounds.origin` keeps it. + private func setExactOffset(_ offset: CGPoint, of scrollView: ScrollView) { + #if canImport(AppKit) + scrollView.contentOffset = offset + #endif + #if canImport(UIKit) + scrollView.bounds.origin = offset + #endif + } } diff --git a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift index 8b03356..a6148f3 100644 --- a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift +++ b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift @@ -247,6 +247,40 @@ class ScrollViewTests: XCTestCase { // then: the visible area covers half as much content along each axis expect(scrollView.visibleSize) == CGSize(width: 50, height: 100) } + + func test_visibleSize_fractionalSize_isExact() { + // given: a 99.2 × 99.2 scroll view showing a 300 × 500 content, whose clip view AppKit rounds to whole pixels + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 99.2, height: 99.2)) + scrollView.contentSize = CGSize(width: 300, height: 500) + + // then: the visible size is exact, and so is the scroll range, as on UIKit, while AppKit rounds the clip view + expect(scrollView.contentView.bounds.size) == CGSize(width: 99, height: 99) + expect(scrollView.visibleSize) == CGSize(width: 99.2, height: 99.2) + expect(scrollView.maxOffsetX) == 300 - 99.2 + expect(scrollView.maxOffsetY) == 500 - 99.2 + + // when: a legacy vertical scroller shows, and the scroll view tiles + scrollView.scrollerStyle = .legacy + scrollView.hasVerticalScroller = true + scrollView.tile() + + // then: the visible size is the exact width beside the scroller + let scrollerWidth = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .legacy) + expect(scrollView.visibleSize) == CGSize(width: 99.2 - scrollerWidth, height: 99.2) + } + + func test_visibleSize_replacedClipView_isTheClipViewSize() { + // given: a 99.2 × 99.2 scroll view + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 99.2, height: 99.2)) + + // when: the clip view is replaced with a plain one, and the scroll view tiles + scrollView.contentView = NSClipView() + scrollView.tile() + + // then: the visible size is the new clip view's size, which AppKit rounds to whole pixels + expect(scrollView.visibleSize) == CGSize(width: 99, height: 99) + expect(scrollView.visibleSize) == scrollView.contentView.bounds.size + } #endif #if canImport(UIKit) @@ -389,6 +423,45 @@ class ScrollViewTests: XCTestCase { expect(parent.scrollWheelEventCount) == 3 } + func test_scrollGesture_nested_withinAPixelOfAnEnd_passesToTheParent() throws { + for restingDistance in [CGFloat(0.3), -0.3] { + // given: a 99.2 × 99.2 scroll view nested in a parent scroll view that can scroll in every direction. AppKit aligns + // where scrolling comes to rest to the window's pixels, so it rests a fraction of a pixel past or short of an end. + let window = TestWindow() + let (parent, scrollView) = Self.makeNestedScrollViews(in: window) + scrollView.frame.size = CGSize(width: 99.2, height: 99.2) + + // when: the offset rests within a pixel of the bottom end, at any display scale, and a gesture scrolls toward the + // bottom + scrollView.contentOffset = CGPoint(x: 0, y: scrollView.maxOffsetY + restingDistance) + try Self.sendScrollGesture(to: scrollView, deltaY: -10) + + // then: the scroll view counts as at the end, and passes the gesture to the parent + expect(parent.scrollWheelEventCount) == 3 + + // when: the offset rests within a pixel of the top end, and a gesture scrolls toward the top + scrollView.contentOffset = CGPoint(x: 0, y: scrollView.minOffsetY - restingDistance) + try Self.sendScrollGesture(to: scrollView, deltaY: 10) + + // then: the scroll view passes the gesture to the parent + expect(parent.scrollWheelEventCount) == 6 + + // when: the offset rests within a pixel of the right end, and a gesture scrolls toward the right + scrollView.contentOffset = CGPoint(x: scrollView.maxOffsetX + restingDistance, y: 0) + try Self.sendScrollGesture(to: scrollView, deltaX: -10) + + // then: the scroll view passes the gesture to the parent + expect(parent.scrollWheelEventCount) == 9 + + // when: the offset rests within a pixel of the left end, and a gesture scrolls toward the left + scrollView.contentOffset = CGPoint(x: scrollView.minOffsetX - restingDistance, y: 0) + try Self.sendScrollGesture(to: scrollView, deltaX: 10) + + // then: the scroll view passes the gesture to the parent + expect(parent.scrollWheelEventCount) == 12 + } + } + func test_scrollGesture_nested_contentSmallerThanTheScrollView_passesToTheParent() throws { // given: a scroll view showing content smaller than it, nested in a parent scroll view that can scroll in every // direction @@ -455,6 +528,23 @@ class ScrollViewTests: XCTestCase { expect(scrollView.horizontalScrollElasticity) == .none expect(scrollView.verticalScrollElasticity) == .none } + + func test_scrollElasticity_fractionalSize_documentAsLargeAsTheView() { + // given: a 99.2 × 99.2 scroll view, whose clip view AppKit rounds down to 99 pt, showing a document as large as the + // scroll view + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 99.2, height: 99.2)) + scrollView.contentSize = CGSize(width: 99.2, height: 99.2) + + // when: the scroll elasticity updates + scrollView.invalidateScrollElasticity() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 1e-3)) + + // then: the document fits the exact visible size, so the scroll view doesn't bounce, even though the document is + // larger than the clip view + expect(scrollView.contentView.bounds.size) == CGSize(width: 99, height: 99) + expect(scrollView.horizontalScrollElasticity) == .none + expect(scrollView.verticalScrollElasticity) == .none + } #endif // MARK: - Helpers From 3cf1b0e85bb737216765ffe2ae69d013f1f5d6d3 Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Thu, 1 Oct 2026 13:23:39 -0700 Subject: [PATCH 2/3] [scroll] measure the one-pixel edge tolerance in content coordinates along each axis On macOS, content offsets are in document points, so on a magnified or scaled scroll view a pixel on screen isn't 1 / windowScaleFactor of them. The tolerance now converts a unit vector along each axis to backing pixels on the clip view, which includes magnification and scaled bounds and keeps the axes apart under rotation. --- .../ScrollView/ScrollView+Scrolling.swift | 8 +- .../CrossPlatform/ScrollView/ScrollView.swift | 23 +++-- .../ScrollView+ScrollingTests.swift | 87 +++++++++++++++++++ .../ScrollView/ScrollViewTests.swift | 28 ++++++ 4 files changed, 137 insertions(+), 9 deletions(-) diff --git a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift index ca29b0e..4cdddd4 100644 --- a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift +++ b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView+Scrolling.swift @@ -64,22 +64,22 @@ public extension ScrollView { /// Whether the scroll view can scroll to the left. var canScrollToLeft: Bool { - contentOffset.x > minOffsetX + pixelLength + contentOffset.x > minOffsetX + pixelSize.width } /// Whether the scroll view can scroll to the right. var canScrollToRight: Bool { - contentOffset.x < maxOffsetX - pixelLength + contentOffset.x < maxOffsetX - pixelSize.width } /// Whether the scroll view can scroll to the top. var canScrollToTop: Bool { - contentOffset.y > minOffsetY + pixelLength + contentOffset.y > minOffsetY + pixelSize.height } /// Whether the scroll view can scroll to the bottom. var canScrollToBottom: Bool { - contentOffset.y < maxOffsetY - pixelLength + contentOffset.y < maxOffsetY - pixelSize.height } #if canImport(UIKit) diff --git a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift index 22e6950..d00f1ec 100644 --- a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift +++ b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift @@ -396,10 +396,10 @@ private extension ScrollView { let offset = contentOffset // AppKit aligns where scrolling comes to rest to the window's pixels, which can leave the offset up to about a // pixel past an end of the exact range, so an offset within a pixel of the range counts as inside it - let pixel = pixelLength + let pixel = pixelSize let minX = minOffsetX let minY = minOffsetY - return offset.x < minX - pixel || offset.x > max(minX, maxOffsetX) + pixel || offset.y < minY - pixel || offset.y > max(minY, maxOffsetY) + pixel + return offset.x < minX - pixel.width || offset.x > max(minX, maxOffsetX) + pixel.width || offset.y < minY - pixel.height || offset.y > max(minY, maxOffsetY) + pixel.height } /// Whether the view has a parent scroll view that satisfies the condition. @@ -451,8 +451,21 @@ extension ScrollView { #endif } - /// The length of a pixel, in points. - var pixelLength: CGFloat { - 1 / windowScaleFactor + /// The size of a pixel in content coordinates. + var pixelSize: CGSize { + #if canImport(AppKit) + // the clip view's coordinates are the content coordinates, so converting from them includes the magnification and + // any scaling of the bounds. each axis converts a unit vector and takes its length, since a rotation would mix the + // axes of a converted size. + let unitX = contentView.convertToBacking(CGSize(width: 1, height: 0)) + let unitY = contentView.convertToBacking(CGSize(width: 0, height: 1)) + return CGSize(width: 1 / hypot(unitX.width, unitX.height), height: 1 / hypot(unitY.width, unitY.height)) + #endif + + #if canImport(UIKit) + // the content offset is in the scroll view's own points, which zooming doesn't scale + let length = 1 / windowScaleFactor + return CGSize(width: length, height: length) + #endif } } diff --git a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollView+ScrollingTests.swift b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollView+ScrollingTests.swift index 34a4716..5048d62 100644 --- a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollView+ScrollingTests.swift +++ b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollView+ScrollingTests.swift @@ -136,6 +136,93 @@ class ScrollView_ScrollingTests: XCTestCase { expect(scrollView.canScrollToBottom) == true } + #if canImport(AppKit) + func test_canScroll_magnified_withinAPixelOfAnEdge() { + for (magnification, withinAPixel, beyondAPixel) in [(CGFloat(4), CGFloat(0.1), CGFloat(0.3)), (0.25, 1.5, 5)] { + // given: a 100 × 100 scroll view showing 2000 × 2000 content, magnified, so that a pixel covers 1 / (scale × + // magnification) points of content: at most 0.25 at 4 times, and at least 2 at a quarter, at 1x or 2x + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + scrollView.contentSize = CGSize(width: 2000, height: 2000) + scrollView.magnification = magnification + + // when: the offset is within a pixel of the minimum offset + scrollView.contentOffset = CGPoint(x: scrollView.minOffsetX + withinAPixel, y: scrollView.minOffsetY + withinAPixel) + + // then: it counts as at the left and top edges + expect(scrollView.canScrollToLeft) == false + expect(scrollView.canScrollToTop) == false + + // when: the offset is within a pixel of the maximum offset + scrollView.contentOffset = CGPoint(x: scrollView.maxOffsetX - withinAPixel, y: scrollView.maxOffsetY - withinAPixel) + + // then: it counts as at the right and bottom edges + expect(scrollView.canScrollToRight) == false + expect(scrollView.canScrollToBottom) == false + + // when: the offset is more than a pixel from the minimum offset + scrollView.contentOffset = CGPoint(x: scrollView.minOffsetX + beyondAPixel, y: scrollView.minOffsetY + beyondAPixel) + + // then: it can scroll to the left and to the top + expect(scrollView.canScrollToLeft) == true + expect(scrollView.canScrollToTop) == true + + // when: the offset is more than a pixel from the maximum offset + scrollView.contentOffset = CGPoint(x: scrollView.maxOffsetX - beyondAPixel, y: scrollView.maxOffsetY - beyondAPixel) + + // then: it can scroll to the right and to the bottom + expect(scrollView.canScrollToRight) == true + expect(scrollView.canScrollToBottom) == true + } + } + + func test_canScroll_nonUniformlyScaledBounds_usesEachAxisPixel() { + // given: a 100 × 100 scroll view showing 2000 × 2000 content, with its bounds scaled 4 times along x and a quarter + // along y, so that a pixel covers at most 0.25 points of content along x and at least 2 along y, at 1x or 2x + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + scrollView.contentSize = CGSize(width: 2000, height: 2000) + scrollView.scaleUnitSquare(to: NSSize(width: 4, height: 0.25)) + scrollView.tile() + + // when: the offset is 1.5 points from the minimum offset along both axes + scrollView.contentOffset = CGPoint(x: scrollView.minOffsetX + 1.5, y: scrollView.minOffsetY + 1.5) + + // then: it can scroll to the left, more than a pixel away along x, and counts as at the top, within a pixel along y + expect(scrollView.canScrollToLeft) == true + expect(scrollView.canScrollToTop) == false + + // when: the offset is 1.5 points from the maximum offset along both axes + scrollView.contentOffset = CGPoint(x: scrollView.maxOffsetX - 1.5, y: scrollView.maxOffsetY - 1.5) + + // then: it can scroll to the right, and counts as at the bottom + expect(scrollView.canScrollToRight) == true + expect(scrollView.canScrollToBottom) == false + } + + func test_canScroll_rotated_withinAPixelOfAnEdge() { + // given: a 100 × 100 scroll view showing 2000 × 2000 content, rotated 45 degrees in a window, where converting a + // whole size to pixels would mix the axes + let window = TestWindow() + let scrollView = ScrollView(frame: CGRect(x: 200, y: 200, width: 100, height: 100)) + scrollView.contentSize = CGSize(width: 2000, height: 2000) + window.contentView().addSubview(scrollView) + scrollView.frameCenterRotation = 45 + + // when: the offset is within a pixel of the minimum offset, at any display scale + scrollView.contentOffset = CGPoint(x: scrollView.minOffsetX + 0.3, y: scrollView.minOffsetY + 0.3) + + // then: it counts as at the left and top edges + expect(scrollView.canScrollToLeft) == false + expect(scrollView.canScrollToTop) == false + + // when: the offset is more than a pixel from the minimum offset, at any display scale + scrollView.contentOffset = CGPoint(x: scrollView.minOffsetX + 1.5, y: scrollView.minOffsetY + 1.5) + + // then: it can scroll to the left and to the top + expect(scrollView.canScrollToLeft) == true + expect(scrollView.canScrollToTop) == true + } + #endif + // MARK: - Helpers /// Sets the content offset exactly, since UIKit snaps a set `contentOffset` to whole pixels, while assigning diff --git a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift index a6148f3..f58b642 100644 --- a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift +++ b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift @@ -462,6 +462,34 @@ class ScrollViewTests: XCTestCase { } } + func test_scrollGesture_nested_nonUniformlyScaledBounds_usesEachAxisPixel() throws { + // given: a scroll view showing 300 × 1000 content, with its bounds scaled 4 times along x and a quarter along y, so + // that a pixel covers at most 0.25 points of content along x and at least 2 along y, at 1x or 2x, nested in a parent + // scroll view that can scroll in every direction + let window = TestWindow() + let (parent, scrollView) = Self.makeNestedScrollViews(in: window) + scrollView.contentSize = CGSize(width: 300, height: 1000) + scrollView.scaleUnitSquare(to: NSSize(width: 4, height: 0.25)) + scrollView.tile() + + // when: the offset rests 1.5 points past the bottom end, within a pixel along y, and a gesture scrolls toward the + // bottom + scrollView.contentOffset = CGPoint(x: 0, y: scrollView.maxOffsetY + 1.5) + try Self.sendScrollGesture(to: scrollView, deltaY: -10) + + // then: the offset counts as inside the scrollable range, so the scroll view passes the gesture to the parent + expect(parent.scrollWheelEventCount) == 3 + + // when: the offset rests 0.3 points past the right end, more than a pixel along x, and a gesture scrolls toward the + // right + scrollView.contentOffset = CGPoint(x: scrollView.maxOffsetX + 0.3, y: 0) + try Self.sendScrollGesture(to: scrollView, deltaX: -10) + + // then: the offset counts as outside the scrollable range, so the scroll view handles the gesture instead of the + // parent + expect(parent.scrollWheelEventCount) == 3 + } + func test_scrollGesture_nested_contentSmallerThanTheScrollView_passesToTheParent() throws { // given: a scroll view showing content smaller than it, nested in a parent scroll view that can scroll in every // direction From 0cbc078b97b567a302d25f7aff3fffa0cdd3e1cf Mon Sep 17 00:00:00 2001 From: Honghao Zhang Date: Thu, 1 Oct 2026 14:12:20 -0700 Subject: [PATCH 3/3] [scroll] scale visibleSize by the clip view's own scale along each axis, instead of by the magnification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A caller can scale the clip view's bounds differently along each axis, which the magnification can't express: with 25 × 40 bounds in a 100 × 100 clip view, AppKit reports a magnification of 4 and visibleSize gave 25 × 25. The size the clip view was given now scales by the clip view's bounds-to-frame ratio along each axis, which also covers magnification and stays exact when nothing is scaled. --- .../CrossPlatform/ScrollView/ScrollView.swift | 10 +++++--- .../ScrollView/ScrollViewTests.swift | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift index d00f1ec..aca16c8 100644 --- a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift +++ b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift @@ -88,10 +88,14 @@ open class ScrollView: NSScrollView { /// /// It's exact, while the clip view rounds its own size to whole pixels. public var visibleSize: CGSize { - guard let size = (contentView as? ScrollClipView)?.unroundedSize else { - return contentView.bounds.size + let boundsSize = contentView.bounds.size + let frameSize = contentView.frame.size + guard let size = (contentView as? ScrollClipView)?.unroundedSize, frameSize.width > 0, frameSize.height > 0 else { + return boundsSize } - return CGSize(width: size.width / magnification, height: size.height / magnification) + // the clip view's bounds are its frame in content coordinates, which magnification or a caller can scale differently + // along each axis, so the size the clip view was given scales the same way + return CGSize(width: size.width * (boundsSize.width / frameSize.width), height: size.height * (boundsSize.height / frameSize.height)) } override public init(frame: CGRect) { diff --git a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift index f58b642..fdb6815 100644 --- a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift +++ b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift @@ -281,6 +281,30 @@ class ScrollViewTests: XCTestCase { expect(scrollView.visibleSize) == CGSize(width: 99, height: 99) expect(scrollView.visibleSize) == scrollView.contentView.bounds.size } + + func test_visibleSize_clipBoundsScaledDifferentlyAlongEachAxis_followsTheClipView() { + // given: a 99.2 × 99.2 scroll view, whose clip view AppKit rounds to 99 pt + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 99.2, height: 99.2)) + + // when: the clip view's bounds scale by 2 along x and by a half along y, which the magnification can't express + scrollView.contentView.scaleUnitSquare(to: NSSize(width: 2, height: 0.5)) + + // then: the visible size scales the exact size by the clip view's scale along each axis + expect(scrollView.visibleSize) == CGSize(width: 49.6, height: 198.4) + } + + func test_visibleSize_zeroSize_isZero() { + // given: a 100 × 100 scroll view + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + + // when: the scroll view shrinks to zero, and tiles + scrollView.frame.size = .zero + scrollView.tile() + + // then: the visible size is zero, instead of dividing by the clip view's zero size + expect(scrollView.contentView.frame.size) == .zero + expect(scrollView.visibleSize) == .zero + } #endif #if canImport(UIKit)