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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
121 changes: 102 additions & 19 deletions ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1549,29 +1642,19 @@ 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.
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 = (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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading