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
30 changes: 24 additions & 6 deletions Sources/FlowStack/CornerRadii.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,33 @@ extension CornerRadii: Animatable {
struct UnevenCornerShape: Shape {
var radii: CornerRadii
var style: RoundedCornerStyle
var renderingRect: CGRect? = nil

var animatableData: CornerRadii.AnimatableData {
get { radii.animatableData }
set { radii.animatableData = newValue }
typealias RectData = AnimatablePair<AnimatablePair<CGFloat, CGFloat>, AnimatablePair<CGFloat, CGFloat>>

var animatableData: AnimatablePair<CornerRadii.AnimatableData, RectData> {
get {
let rect = renderingRect ?? .zero
return AnimatablePair(radii.animatableData, AnimatablePair(
AnimatablePair(rect.origin.x, rect.origin.y),
AnimatablePair(rect.width, rect.height)
))
}
set {
radii.animatableData = newValue.first
if renderingRect != nil {
renderingRect = CGRect(
x: newValue.second.first.first, y: newValue.second.first.second,
width: newValue.second.second.first, height: newValue.second.second.second
)
}
}
}

func path(in rect: CGRect) -> Path {
let pathRect = renderingRect ?? rect
if radii.isUniform {
return RoundedRectangle(cornerRadius: radii.topLeft, style: style).path(in: rect)
return RoundedRectangle(cornerRadius: radii.topLeft, style: style).path(in: pathRect)
}

if #available(iOS 16.0, *) {
Expand All @@ -87,9 +105,9 @@ struct UnevenCornerShape: Shape {
bottomTrailing: radii.bottomRight,
topTrailing: radii.topRight
)
return Path(roundedRect: rect, cornerRadii: cornerRadii, style: style)
return Path(roundedRect: pathRect, cornerRadii: cornerRadii, style: style)
}

return RoundedRectangle(cornerRadius: radii.maximum, style: style).path(in: rect)
return RoundedRectangle(cornerRadius: radii.maximum, style: style).path(in: pathRect)
}
}
64 changes: 53 additions & 11 deletions Sources/FlowStack/FlowTransition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,13 +301,42 @@ extension AnyTransition {
.allowsHitTesting(isPresented)
}

// A scaled fullscreen destination needs its final UIKit layout before the first
// visible frame. Only its rendering changes size; its navigation bars and safe area
// keep the same layout throughout the zoom and an interactive pull.
private func usesStableLayout(availableSize: CGSize) -> Bool {
if #available(iOS 27.0, *) {
return isPresentedFullscreen(availableSize: availableSize) && resolvedContext.shouldScaleHorizontally
}
return false
}

@ViewBuilder
private func presentation(of content: Content, in proxy: GeometryProxy) -> some View {
let zoomRect = zoomRect(with: proxy, anchor: resolvedContext.overrideAnchor ?? resolvedContext.anchor, percent: percent, pullOffset: panOffset)
let scaleRatio = resolvedContext.shouldScaleHorizontally ? zoomRect.size.width / proxy.size.width : 1.0

let usesStableLayout = usesStableLayout(availableSize: proxy.size)
let slideOffset = resolvedContext.anchor == nil ? (1 - percent) * proxy.size.width : 0
let renderingRect = CGRect(
x: zoomRect.origin.x - zoomRect.width / 2 + slideOffset,
y: zoomRect.origin.y - zoomRect.height / 2,
width: zoomRect.width,
height: zoomRect.height
)
// Cover even a source that's taller in proportion than the destination. The
// mask supplies the zoom's viewport, while the content keeps its resting bounds.
let renderScale = max(zoomRect.width / proxy.size.width, zoomRect.height / proxy.size.height)
let renderTransform = FlowRenderTransform(
scale: renderScale,
translation: CGPoint(
x: zoomRect.origin.x - proxy.size.width / 2 + slideOffset,
y: renderingRect.minY + proxy.size.height * (renderScale - 1) / 2
)
)

content
.onInteractiveDismissGesture(threshold: 80, isEnabled: !isDisabled, isDismissing: isDismissing, swipeUpToDismiss: resolvedContext.swipeUpToDismiss, onDismiss: {
.onInteractiveDismissGesture(threshold: 80, isEnabled: !isDisabled, isDismissing: isDismissing, swipeUpToDismiss: resolvedContext.swipeUpToDismiss, renderTransform: usesStableLayout ? renderTransform : nil, onDismiss: {
guard !isDisabled else { return }
dismiss()
isDismissing = true
Expand Down Expand Up @@ -336,23 +365,36 @@ extension AnyTransition {
}
.overlay(alignment: .top) {
if let image = activeSnapshot, percent < 1 {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.opacity(snapshotPercent)
if usesStableLayout {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: renderingRect.width, height: renderingRect.height, alignment: .top)
.position(x: renderingRect.midX, y: renderingRect.midY)
.opacity(snapshotPercent)
} else {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.opacity(snapshotPercent)
}
}
}
.clipShape(UnevenCornerShape(radii: cornerRadii(with: proxy).map { $0 / scaleRatio }, style: cornerStyle))
.clipShape(UnevenCornerShape(
radii: cornerRadii(with: proxy).map { usesStableLayout ? $0 : $0 / scaleRatio },
style: cornerStyle,
renderingRect: usesStableLayout ? renderingRect : nil
))
.shadow(color: resolvedContext.shadowColor ?? .clear, radius: resolvedContext.shadowRadius, x: resolvedContext.shadowOffset.x, y: resolvedContext.shadowOffset.y)
.frame(
width: resolvedContext.shouldScaleHorizontally ? proxy.size.width : zoomRect.size.width,
height: zoomRect.size.height / scaleRatio
height: usesStableLayout ? proxy.size.height : zoomRect.size.height / scaleRatio
)
.scaleEffect(x: scaleRatio, y: scaleRatio, anchor: .center)
.transformEffect(.init(translationX: resolvedContext.anchor == nil ? (1 - percent) * proxy.size.width : 0, y: 0))
.scaleEffect(x: usesStableLayout ? 1 : scaleRatio, y: usesStableLayout ? 1 : scaleRatio, anchor: .center)
.transformEffect(.init(translationX: usesStableLayout ? 0 : slideOffset, y: 0))
.position(
x: zoomRect.origin.x,
y: zoomRect.origin.y
x: usesStableLayout ? proxy.size.width / 2 : zoomRect.origin.x,
y: usesStableLayout ? proxy.size.height / 2 : zoomRect.origin.y
)
.opacity(resolvedContext.anchor == nil ? percent : 1)
}
Expand Down
Loading
Loading