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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
- 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.
- 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 @@ -93,11 +93,11 @@ public extension RenderableTransition {
case .top:
startFrame = targetFrame.translate(dy: -targetFrame.maxY - overshoot)
case .bottom:
startFrame = targetFrame.translate(dy: context.contentView.bounds().height - targetFrame.minY + overshoot)
startFrame = targetFrame.translate(dy: context.contentView.visibleSize.height - targetFrame.minY + overshoot)
case .left:
startFrame = targetFrame.translate(dx: -targetFrame.maxX - overshoot)
case .right:
startFrame = targetFrame.translate(dx: context.contentView.bounds().width - targetFrame.minX + overshoot)
startFrame = targetFrame.translate(dx: context.contentView.visibleSize.width - targetFrame.minX + overshoot)
}
startPosition = layer.position(from: startFrame)
}
Expand Down Expand Up @@ -130,11 +130,11 @@ public extension RenderableTransition {
case .top:
targetFrame = currentFrame.translate(dy: -currentFrame.maxY - overshoot)
case .bottom:
targetFrame = currentFrame.translate(dy: context.contentView.bounds().height - currentFrame.minY + overshoot)
targetFrame = currentFrame.translate(dy: context.contentView.visibleSize.height - currentFrame.minY + overshoot)
case .left:
targetFrame = currentFrame.translate(dx: -currentFrame.maxX - overshoot)
case .right:
targetFrame = currentFrame.translate(dx: context.contentView.bounds().width - currentFrame.minX + overshoot)
targetFrame = currentFrame.translate(dx: context.contentView.visibleSize.width - currentFrame.minX + overshoot)
}

guard timing.timing.duration > 0 || timing.delay > 0 else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public extension ComposeView {
case renderDidReceiveRenderableItems(renderableItems: [RenderableItem], contentSize: CGSize)

/// The scrollable behavior has been updated.
case renderDidUpdateScrollableBehavior(isScrollable: Bool, alwaysBounceHorizontal: Bool, alwaysBounceVertical: Bool)
case renderDidUpdateScrollableBehavior(isScrollEnabled: Bool, alwaysBounceHorizontal: Bool, alwaysBounceVertical: Bool)

/// The clipping behavior has been updated.
case renderDidUpdateClippingBehavior(clipsToBounds: Bool)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ extension ComposeView {
/// - renderableItemIds: The ids of the renderables being rendered, in z-order (back to front).
/// - renderableMap: The map of the renderables being rendered, keyed by id.
func placeNewRenderables(reusingIds: Set<ComposeNodeId>, renderableItemIds: [ComposeNodeId], renderableMap: [ComposeNodeId: Renderable]) {
let parent: View = contentView()
let parent = contentContainerView

// the next view sibling (already placed correctly), while walking from front to back
var nextViewSibling: View?
Expand Down
24 changes: 12 additions & 12 deletions ComposeUI/Sources/ComposeUI/ComposeView/ComposeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ open class ComposeView: BaseScrollView {
/// The view is scrollable if the content is larger than the view's bounds. Otherwise, the view is not scrollable.
case auto

/// The view does not modify scroll settings. `isScrollable` and `alwaysBounceHorizontal`/`alwaysBounceVertical` are managed by you.
/// The view does not modify scroll settings. `isScrollEnabled` and `alwaysBounceHorizontal`/`alwaysBounceVertical` are managed by you.
case manual

/// The view is always scrollable. The view will always bounce.
Expand Down Expand Up @@ -1000,33 +1000,33 @@ open class ComposeView: BaseScrollView {
}

// set content size
setContentSize(roundedContentSize)
self.contentSize = roundedContentSize

// update scrollable behavior
switch scrollBehavior {
case .auto:
isScrollable = overflowsHorizontally || overflowsVertically
isScrollEnabled = overflowsHorizontally || overflowsVertically
alwaysBounceHorizontal = false
alwaysBounceVertical = false
case .manual:
break
case .always:
isScrollable = true
isScrollEnabled = true
alwaysBounceHorizontal = true
alwaysBounceVertical = true
case .never:
isScrollable = false
isScrollEnabled = false
alwaysBounceHorizontal = false
alwaysBounceVertical = false
}

#if DEBUG
debug?.onEvent(.renderDidUpdateScrollableBehavior(isScrollable: isScrollable, alwaysBounceHorizontal: alwaysBounceHorizontal, alwaysBounceVertical: alwaysBounceVertical))
debug?.onEvent(.renderDidUpdateScrollableBehavior(isScrollEnabled: isScrollEnabled, alwaysBounceHorizontal: alwaysBounceHorizontal, alwaysBounceVertical: alwaysBounceVertical))
#endif

switch clippingBehavior {
case .auto:
clipsToBounds = isScrollable
clipsToBounds = isScrollEnabled
case .manual:
break
case .always:
Expand Down Expand Up @@ -1074,7 +1074,7 @@ open class ComposeView: BaseScrollView {
// updating the content size or the scroll indicators can move the scroll offset: on AppKit, hiding a legacy
// scroller grows the clip view, which can clamp the offset. so read the offset after both, to render the viewport
// the view ends up with.
bounds.origin = contentOffset()
bounds.origin = contentOffset

if let willRenderHandler {
willRenderHandler(self, WillRenderContext(contentSize: roundedContentSize, renderBounds: bounds, renderType: context.renderType(bounds: bounds)))
Expand Down Expand Up @@ -1420,7 +1420,7 @@ open class ComposeView: BaseScrollView {

renderableItem.willUpdate?(renderable, renderableUpdateContext)

renderable.addToParent(contentView())
renderable.addToParent(contentContainerView)
renderable.assertIdentityTransform()
renderable.setFrame(newFrame)

Expand Down Expand Up @@ -1549,7 +1549,7 @@ 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 (`bounds()`), so the size comes from the view's bounds inside the border,
// 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
Expand All @@ -1571,11 +1571,11 @@ open class ComposeView: BaseScrollView {
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: bounds().origin, size: CGSize(width: width / magnification, height: height / magnification))
return CGRect(origin: contentOffset, size: CGSize(width: width / magnification, height: height / magnification))
#endif

#if canImport(UIKit)
return bounds()
return bounds
#endif
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,46 +105,6 @@
}
#endif

// MARK: - Scroll

/// Whether the scroll view is scrollable.
public var isScrollable: Bool {
get {
#if canImport(UIKit)
return isScrollEnabled
#endif

#if canImport(AppKit)
return _isScrollable
#endif
}
set {
#if canImport(UIKit)
isScrollEnabled = newValue
#endif

#if canImport(AppKit)
_isScrollable = newValue
#endif
}
}

#if canImport(AppKit)
private var _isScrollable: Bool = true

override open func scrollWheel(with event: NSEvent) {
// https://apptyrant.com/2015/05/18/how-to-disable-nsscrollview-scrolling/
guard _isScrollable else {
// send the event to outside of the scroll view.
// https://github.com/onmyway133/blog/issues/733
nextResponder?.scrollWheel(with: event)
return
}

super.scrollWheel(with: event)
}
#endif

// MARK: - Theme

/// A publisher that emits the theme of the view.
Expand Down Expand Up @@ -301,7 +261,7 @@
}

override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)

Check warning on line 264 in ComposeUI/Sources/ComposeUI/CrossPlatform/ScrollView/BaseScrollView.swift

View workflow job for this annotation

GitHub Actions / Test (visionOS)

'traitCollectionDidChange' was deprecated in visionOS 1.0: Use the trait change registration APIs declared in the UITraitChangeObservable protocol

scheduleThemeUpdate(theme)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//
// ScrollView+Scrolling.swift
// ComposéUI
//
// Created by Honghao Zhang on 9/30/26.
// Copyright © 2024 Honghao Zhang.
//
// MIT License
//
// Copyright (c) 2024 Honghao Zhang (github.com/honghaoz)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
//

#if canImport(AppKit)
import AppKit
#endif

#if canImport(UIKit)
import UIKit
#endif

public extension ScrollView {

/// The minimum horizontal offset that the scroll view can scroll to, without elastic effect.
var minOffsetX: CGFloat {
-adjustedContentInset.left
}

/// The maximum horizontal offset that the scroll view can scroll to, without elastic effect.
var maxOffsetX: CGFloat {
contentSize.width - visibleSize.width + adjustedContentInset.right
Comment thread
honghaoz marked this conversation as resolved.
}

/// The minimum vertical offset that the scroll view can scroll to, without elastic effect.
var minOffsetY: CGFloat {
-adjustedContentInset.top
}

/// The maximum vertical offset that the scroll view can scroll to, without elastic effect.
var maxOffsetY: CGFloat {
contentSize.height - visibleSize.height + adjustedContentInset.bottom
}

/// Whether the scroll view can scroll to the left.
var canScrollToLeft: Bool {
contentOffset.x > minOffsetX
}

/// Whether the scroll view can scroll to the right.
var canScrollToRight: Bool {
contentOffset.x < maxOffsetX
}

/// Whether the scroll view can scroll to the top.
var canScrollToTop: Bool {
contentOffset.y > minOffsetY
}

/// Whether the scroll view can scroll to the bottom.
var canScrollToBottom: Bool {
contentOffset.y < maxOffsetY
}

#if canImport(UIKit)
/// Stops any ongoing scroll deceleration, freezing the scroll view at its current position.
func stopDecelerating() {
setContentOffset(contentOffset, animated: false)
}
#endif
}

// TODO: clarify setContentOffset with animation or not
// we need to clean API for setting content offset with/without animation
// it seems like UIKit's `contentOffset = " will adjust the offset without affecting live animations
// while `self.setContentOffset(..., animated:)` can set offset and stop the live animations
Loading
Loading