diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f8e6e..15a065c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - Zero-duration transitions now call their completion, and a completion is also called when its animation is torn down early. - `RenderableTransition.opacity`'s `to` is now optional, and without it the fade ends at the opacity the content sets, for example with the `opacity` modifier, instead of 1. - `ComposeView.tile()` is now final on macOS. The content lays out for the view's bounds inside the border whether or not scroll bars show, so tiling that takes space from the clip view would cover the content. Place accessory views outside the `ComposeView` instead. -- Removed `ScrollViewType`. `ScrollView` now has UIKit's scroll view API on macOS too: `contentOffset` replaces `contentOffset()` and `setContentOffset(_:)`, `contentSize` replaces `contentSize()` and `setContentSize(_:)`, `adjustedContentInset` replaces `contentInsets()`, `contentInset` replaces `setContentInsets(_:)`, and `contentOffset` with `visibleSize` replace `bounds()`. To keep `setBounds(_:)`'s behavior, assign `bounds` on iOS, tvOS and visionOS, which resizes the view around its center, and on macOS set `frame.size`, then `contentOffset`, as it did there except for ignoring the x offset. Setting `frame.size`, then `contentOffset` resizes from the view's origin on every platform. `contentView()` and the macOS `documentView()` are no longer public: on macOS, `contentView()` shared its name with AppKit's `NSScrollView.contentView()`, so a call in an optional or inferred context returned the clip view instead of the document view. Use `documentView` on macOS, and the scroll view itself on iOS, tvOS and visionOS. +- 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:)`. ### Changes diff --git a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift index 0ecde6c..5bb3da2 100644 --- a/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift +++ b/ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/ScrollView.swift @@ -52,13 +52,16 @@ open class ScrollView: NSScrollView { /// The offset of the visible area's origin from the content's origin, like `UIScrollView`'s `contentOffset`. /// - /// Unlike on UIKit, setting it keeps the offset within the scrollable range. + /// As on UIKit, setting it keeps the offset as set, even outside the scrollable range. public var contentOffset: CGPoint { get { contentView.bounds.origin } set { - contentView.scroll(newValue) + // `NSClipView.scroll(to:)` keeps the point as given, while `NSView.scroll(_:)` clamps it into the scrollable + // range and adds floating-point noise + contentView.scroll(to: newValue) + reflectScrolledClipView(contentView) } } @@ -334,11 +337,18 @@ private final class ScrollSession { // decide if the scroll view should handle the scroll event by itself // - // the core logic is: given the scrolling direction, if the scroll view is configured to always bounce, or can scroll to the direction, let it handle the scroll event. - // otherwise, if the scroll view has a parent scroll view that can scroll to the direction, let the parent scroll view handle the scroll event. - // if there's no parent scroll view that can scroll to the direction, let the scroll view handle the scroll event by itself so that it can bounce (elasticity). - - if (event.scrollingDeltaY > 0 && (scrollView.alwaysBounceVertical || scrollView.canScrollToTop || !scrollView.hasParentScrollView { $0.canScrollToTop })) || + // the core logic is: if the scroll view's offset is outside its scrollable range, for example set so in code or + // still bouncing back, let it handle the scroll event, so that AppKit brings the offset back into the range. a + // parent scroll view handling the event would leave the offset outside the range. + // otherwise, given the scrolling direction, if the scroll view is configured to always bounce, or can scroll to + // the direction, let it handle the scroll event. + // otherwise, if the scroll view has a parent scroll view that can scroll to the direction, let the parent scroll + // view handle the scroll event. + // if there's no parent scroll view that can scroll to the direction, let the scroll view handle the scroll event + // by itself so that it can bounce (elasticity). + + if scrollView.isContentOffsetOutsideScrollableRange || + (event.scrollingDeltaY > 0 && (scrollView.alwaysBounceVertical || scrollView.canScrollToTop || !scrollView.hasParentScrollView { $0.canScrollToTop })) || (event.scrollingDeltaY < 0 && (scrollView.alwaysBounceVertical || scrollView.canScrollToBottom || !scrollView.hasParentScrollView { $0.canScrollToBottom })) || (event.scrollingDeltaX > 0 && (scrollView.alwaysBounceHorizontal || scrollView.canScrollToLeft || !scrollView.hasParentScrollView { $0.canScrollToLeft })) || (event.scrollingDeltaX < 0 && (scrollView.alwaysBounceHorizontal || scrollView.canScrollToRight || !scrollView.hasParentScrollView { $0.canScrollToRight })) @@ -370,6 +380,17 @@ private final class ScrollSession { private extension ScrollView { + /// Whether the content offset is outside the scrollable range along either axis. + /// + /// 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 + let minX = minOffsetX + let minY = minOffsetY + return offset.x < minX || offset.x > max(minX, maxOffsetX) || offset.y < minY || offset.y > max(minY, maxOffsetY) + } + /// Whether the view has a parent scroll view that satisfies the condition. func hasParentScrollView(_ condition: (ScrollView) -> Bool) -> Bool { var parent = superview diff --git a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift index 6acebf2..1d734a3 100644 --- a/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift +++ b/ComposeUI/Tests/ComposeUITests/ComposeView/ComposeView+RenderHandlerTests.swift @@ -311,9 +311,20 @@ class ComposeView_RenderHandlerTests: XCTestCase { // when: the view is resized view.frame.size = CGSize(width: 150, height: 150) + + // then: AppKit renders the resize right away, where the handler moves the offset past the new end, and UIKit waits + // for the next layout pass, keeping the offset within the new range until then + #if canImport(AppKit) + expect(willRenderCallCount) == 3 + expect(view.contentOffset) == CGPoint(x: 0, y: 100) + #endif + #if canImport(UIKit) + expect(willRenderCallCount) == 2 expect(view.contentOffset) == CGPoint(x: 0, y: 50) // y: 50 (maxOffsetY) = 200 - 150 + #endif expect(view.visibleSize) == CGSize(width: 150, height: 150) + // when: the view lays out view.layoutIfNeeded() // then: the will-render handler should be called with the correct arguments @@ -321,22 +332,13 @@ class ComposeView_RenderHandlerTests: XCTestCase { expect(willRenderContentSize) == CGSize(width: 150, height: 200) expect(willRenderRenderBounds) == CGRect(x: 0, y: 50, width: 150, height: 150) expect(willRenderRenderType) == .boundsChange(previousBounds: CGRect(x: 0, y: 100, width: 100, height: 100), bounds: CGRect(x: 0, y: 50, width: 150, height: 150)) - #if canImport(AppKit) - expect(view.contentOffset.y) == 50 // AppKit doesn't allow over scroll - #endif - #if canImport(UIKit) + // the handler's offset is past the new end, and setting it keeps it as set expect(view.contentOffset.y) == 100 - #endif expect(eventOrder) == [ "willRender", "renderItems", "willRender", "renderItems", "willRender", "renderItems", ] - #if canImport(AppKit) - expect(requestedVisibleBounds) == CGRect(x: -25, y: 50, width: 150, height: 150) // AppKit doesn't allow over scroll - #endif - #if canImport(UIKit) expect(requestedVisibleBounds) == CGRect(x: -25, y: 100, width: 150, height: 150) - #endif } func test_boundsChange_renderTypeMatchesTheViewportWhenReported() throws { diff --git a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift index ea09467..8b03356 100644 --- a/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift +++ b/ComposeUI/Tests/ComposeUITests/CrossPlatform/ScrollView/ScrollViewTests.swift @@ -62,6 +62,20 @@ class ScrollViewTests: XCTestCase { #endif } + #if canImport(AppKit) + func test_contentOffset_fractional() { + // given: a 100 × 200 scroll view showing a 300 × 500 content + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + scrollView.contentSize = CGSize(width: 300, height: 500) + + // when: setting a fractional content offset + scrollView.contentOffset = CGPoint(x: 10.3, y: 20.3) + + // then: the offset reads back exactly as set + expect(scrollView.contentOffset) == CGPoint(x: 10.3, y: 20.3) + } + #endif + func test_contentOffset_outsideScrollableRange() { // given: a 100 × 200 scroll view showing a 300 × 500 content let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) @@ -70,15 +84,61 @@ class ScrollViewTests: XCTestCase { // when: setting a content offset past the start of the horizontal range and the end of the vertical range scrollView.contentOffset = CGPoint(x: -50, y: 1000) - // then: AppKit keeps the offset within the scrollable range, and UIKit keeps it as set - #if canImport(AppKit) - expect(scrollView.contentOffset) == CGPoint(x: 0, y: 300) - #endif - #if canImport(UIKit) + // then: the offset stays as set expect(scrollView.contentOffset) == CGPoint(x: -50, y: 1000) - #endif + + // when: the scroll view resizes + scrollView.frame.size = CGSize(width: 100, height: 210) + + // then: the offset comes back into the scrollable range + expect(scrollView.contentOffset) == CGPoint(x: 0, y: 290) + } + + #if canImport(AppKit) + func test_contentOffset_outsideScrollableRange_scrollWheel() throws { + // given: a 100 × 200 scroll view in a window, showing content 500 tall, so it scrolls 300 + let window = TestWindow() + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + scrollView.contentSize = CGSize(width: 100, height: 500) + window.contentView().addSubview(scrollView) + let cgEvent = try unwrap(CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 1, wheel1: -1, wheel2: 0, wheel3: 0)) + let event = try unwrap(NSEvent(cgEvent: cgEvent)) + + // when: the offset is past the end, and the run loop turns + scrollView.contentOffset = CGPoint(x: 0, y: 1000) + wait(timeout: 0.05) + + // then: the offset stays as set + expect(scrollView.contentOffset) == CGPoint(x: 0, y: 1000) + + // when: the scroll view gets a one-line mouse wheel scroll + scrollView.scrollWheel(with: event) + + // then: the offset comes back to the end of the scrollable range, once AppKit applies the scroll on the run loop + expect(scrollView.contentOffset).toEventually(beEqual(to: CGPoint(x: 0, y: 300))) + + // when: the offset is before the start, and the scroll view gets the scroll again + scrollView.contentOffset = CGPoint(x: 0, y: -100) + scrollView.scrollWheel(with: event) + + // then: the offset comes back to the start of the scrollable range + expect(scrollView.contentOffset).toEventually(beEqual(to: .zero)) } + func test_contentOffset_movesTheScroller() { + // given: a 100 × 200 scroll view with a vertical scroller, showing content 500 tall, so it scrolls 300 + let scrollView = ScrollView(frame: CGRect(x: 0, y: 0, width: 100, height: 200)) + scrollView.contentSize = CGSize(width: 100, height: 500) + scrollView.hasVerticalScroller = true + + // when: scrolling halfway + scrollView.contentOffset = CGPoint(x: 0, y: 150) + + // then: the scroller's knob is halfway along its track + expect(scrollView.verticalScroller?.doubleValue) == 0.5 + } + #endif + // MARK: - Content Size func test_contentSize() { @@ -246,9 +306,11 @@ class ScrollViewTests: XCTestCase { let cgEvent = try unwrap(CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 1, wheel1: -30, wheel2: 0, wheel3: 0)) let event = try unwrap(NSEvent(cgEvent: cgEvent)) - // when: scrolling is disabled and the scroll view gets a scroll wheel event + // when: scrolling is disabled, the scroll view gets a scroll wheel event, and the run loop turns, which is when + // AppKit applies a scroll scrollView.isScrollEnabled = false scrollView.scrollWheel(with: event) + wait(timeout: 0.1) // then: the event passes to the next responder, and the scroll view doesn't scroll expect(container.scrollWheelEventCount) == 1 @@ -258,8 +320,90 @@ class ScrollViewTests: XCTestCase { scrollView.isScrollEnabled = true scrollView.scrollWheel(with: event) - // then: the scroll view handles the event instead of passing it on + // then: the scroll view scrolls instead of passing the event on expect(container.scrollWheelEventCount) == 1 + expect(scrollView.contentOffset).toEventuallyNot(beEqual(to: .zero)) + } + #endif + + // MARK: - Scroll Routing + + #if canImport(AppKit) + func test_scrollGesture_nested_outsideScrollableRange_comesBackIntoRange() throws { + // given: a scroll view that scrolls from 0 to 200 along both axes, nested in a parent scroll view that can scroll in + // every direction + let window = TestWindow() + let (parent, scrollView) = Self.makeNestedScrollViews(in: window) + + // when: the offset is past the bottom end, and a gesture scrolls further toward the bottom + scrollView.contentOffset = CGPoint(x: 0, y: 300) + try Self.sendScrollGesture(to: scrollView, deltaY: -10) + + // then: the scroll view handles the gesture instead of the parent, and AppKit brings the offset back to the end + expect(parent.scrollWheelEventCount) == 0 + expect(scrollView.contentOffset).toEventually(beEqual(to: CGPoint(x: 0, y: 200))) + + // when: the offset is before the top end, and a gesture scrolls further toward the top + scrollView.contentOffset = CGPoint(x: 0, y: -100) + try Self.sendScrollGesture(to: scrollView, deltaY: 10) + + // then: the scroll view handles the gesture, and AppKit brings the offset back to the start + expect(parent.scrollWheelEventCount) == 0 + expect(scrollView.contentOffset).toEventually(beEqual(to: .zero)) + + // when: the offset is past the right end, and a gesture scrolls further toward the right + scrollView.contentOffset = CGPoint(x: 300, y: 0) + try Self.sendScrollGesture(to: scrollView, deltaX: -10) + + // then: the scroll view handles the gesture, and AppKit brings the offset back to the end + expect(parent.scrollWheelEventCount) == 0 + expect(scrollView.contentOffset).toEventually(beEqual(to: CGPoint(x: 200, y: 0))) + + // when: the offset is before the left end, and a gesture scrolls further toward the left + scrollView.contentOffset = CGPoint(x: -100, y: 0) + try Self.sendScrollGesture(to: scrollView, deltaX: 10) + + // then: the scroll view handles the gesture, and AppKit brings the offset back to the start + expect(parent.scrollWheelEventCount) == 0 + expect(scrollView.contentOffset).toEventually(beEqual(to: .zero)) + } + + func test_scrollGesture_nested_atTheEnd_passesToTheParent() throws { + // given: a scroll view at the bottom end of its range, nested in a parent scroll view that can scroll in every + // direction + let window = TestWindow() + let (parent, scrollView) = Self.makeNestedScrollViews(in: window) + scrollView.contentOffset = CGPoint(x: 0, y: 200) + + // when: a gesture scrolls toward the bottom + try Self.sendScrollGesture(to: scrollView, deltaY: -10) + + // then: the parent gets every event of the gesture, and the scroll view stays at the end + expect(parent.scrollWheelEventCount) == 3 + expect(scrollView.contentOffset) == CGPoint(x: 0, y: 200) + + // when: a gesture scrolls toward the top, which the scroll view can scroll to + try Self.sendScrollGesture(to: scrollView, deltaY: 10) + + // then: 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 + let window = TestWindow() + let (parent, scrollView) = Self.makeNestedScrollViews(in: window) + scrollView.contentSize = CGSize(width: 50, height: 50) + + // when: a gesture scrolls toward the bottom + try Self.sendScrollGesture(to: scrollView, deltaY: -10) + + // then: the maximum offset is below the minimum, and the offset at the minimum is within the scrollable range, so + // the parent gets the gesture + expect(scrollView.maxOffsetY) < scrollView.minOffsetY + expect(scrollView.contentOffset) == .zero + expect(parent.scrollWheelEventCount) == 3 } #endif @@ -319,6 +463,33 @@ class ScrollViewTests: XCTestCase { private static func components(of insets: EdgeInsets) -> [CGFloat] { [insets.top, insets.left, insets.bottom, insets.right] } + + #if canImport(AppKit) + /// Makes a 100 × 100 scroll view showing 300 × 300 content, so it scrolls from 0 to 200 along both axes, nested in + /// a 200 × 200 parent scroll view scrolled to the middle of its 1000 × 1000 content. + private static func makeNestedScrollViews(in window: TestWindow) -> (parent: ScrollWheelRecordingScrollView, scrollView: ScrollView) { + let parent = ScrollWheelRecordingScrollView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + parent.contentSize = CGSize(width: 1000, height: 1000) + parent.contentOffset = CGPoint(x: 400, y: 400) + window.contentView().addSubview(parent) + + let scrollView = ScrollView(frame: CGRect(x: 400, y: 400, width: 100, height: 100)) + scrollView.contentSize = CGSize(width: 300, height: 300) + parent.documentView?.addSubview(scrollView) + return (parent, scrollView) + } + + /// Sends the events of a trackpad scroll gesture to the scroll view: one that begins and one that changes, both with + /// the deltas, then one that ends. + private static func sendScrollGesture(to scrollView: ScrollView, deltaX: Int32 = 0, deltaY: Int32 = 0) throws { + for phase in [CGScrollPhase.began, .changed, .ended] { + let isEnded = phase == .ended + let cgEvent = try unwrap(CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: isEnded ? 0 : deltaY, wheel2: isEnded ? 0 : deltaX, wheel3: 0)) + cgEvent.setIntegerValueField(.scrollWheelEventScrollPhase, value: Int64(phase.rawValue)) + try scrollView.scrollWheel(with: unwrap(NSEvent(cgEvent: cgEvent))) + } + } + #endif } #if canImport(AppKit) @@ -330,6 +501,15 @@ private final class ScrollWheelRecordingView: NSView { scrollWheelEventCount += 1 } } + +private final class ScrollWheelRecordingScrollView: ScrollView { + + private(set) var scrollWheelEventCount = 0 + + override func scrollWheel(with event: NSEvent) { + scrollWheelEventCount += 1 + } +} #endif #if canImport(UIKit)