Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
reflectScrolledClipView(contentView)
}
}

Expand Down Expand Up @@ -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 }))
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,32 +311,34 @@ 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
expect(willRenderCallCount) == 3
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading