Skip to content
Open
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
41 changes: 30 additions & 11 deletions Sources/FlowStack/FlowLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,10 @@ public struct FlowLink<Label>: View where Label: View {
/// - shadowColor: The shadow color applied to the transitioning destination view. This value should typically match the shadow color of the flow link contents or flow link animation anchor for visual consistency.
/// - shadowOffset: The shadow offset applied to the transitioning destination view. This value should typically match the shadow offset of the flow link contents or flow link animation anchor for visual consistency.
/// - zoomStyle: The zoom style applied to the transitioning destination view
public init(animateFromAnchor: Bool = true, transitionFromSnapshot: Bool = true, cornerRadius: CGFloat = 0, cornerStyle: RoundedCornerStyle = .circular, shadowRadius: CGFloat = 0, shadowColor: Color? = nil, shadowOffset: CGPoint = .zero, zoomStyle: ZoomStyle = .scaleHorizontally) {
public init(animateFromAnchor: Bool = true, transitionFromSnapshot: Bool = true, transitionWithOpacity: Bool = false, cornerRadius: CGFloat = 0, cornerStyle: RoundedCornerStyle = .circular, shadowRadius: CGFloat = 0, shadowColor: Color? = nil, shadowOffset: CGPoint = .zero, zoomStyle: ZoomStyle = .scaleHorizontally) {
self.animateFromAnchor = animateFromAnchor
self.transitionFromSnapshot = transitionFromSnapshot
self.transitionWithOpacity = transitionWithOpacity
self.cornerRadius = cornerRadius
self.cornerStyle = cornerStyle
self.shadowRadius = shadowRadius
Expand All @@ -230,6 +231,8 @@ public struct FlowLink<Label>: View where Label: View {
let animateFromAnchor: Bool
let transitionFromSnapshot: Bool

let transitionWithOpacity: Bool

let cornerRadius: CGFloat
let cornerStyle: RoundedCornerStyle

Expand All @@ -250,6 +253,7 @@ public struct FlowLink<Label>: View where Label: View {
@Environment(\.flowDepth) private var flowDepth
@Environment(\.flowTransaction) private var transaction
@Environment(\.flowAnimationDuration) private var flowDuration
@SwiftUI.Environment(\.opacityTransitionPercent) var percent

@Environment(\.colorScheme) private var colorScheme
@Environment(\.self) private var fetchedEnvironment
Expand Down Expand Up @@ -390,9 +394,7 @@ public struct FlowLink<Label>: View where Label: View {
if configuration.animateFromAnchor && overrideAnchor == nil {
button
.opacity(isShowing ? 1.0 : 0.0)
/// (Workaround) Override an animation with an animation that does nothing
/// Leaving a flowlayer too early can cause an un-wanted animation
.ignoreAnimation()
.ifTransitionWithOpacity(configuration.transitionWithOpacity)
} else if configuration.animateFromAnchor {
button
.transition(.opacityPercent)
Expand All @@ -403,7 +405,6 @@ public struct FlowLink<Label>: View where Label: View {
}
.onChange(of: colorScheme) { newScheme in
refreshButton = UUID()
snapshots[newScheme]
path?.wrappedValue.updateSnapshots(from: newScheme)
}
.onAppear { initSnapshots() }
Expand Down Expand Up @@ -436,7 +437,8 @@ public struct FlowLink<Label>: View where Label: View {
shadowColor: configuration.shadowColor,
shadowOffset: configuration.shadowOffset,
shouldShowSkrim: configuration.showsSkrim,
shouldScaleHorizontally: configuration.zoomStyle == .scaleHorizontally
shouldScaleHorizontally: configuration.zoomStyle == .scaleHorizontally,
transitionWithOpacity: configuration.transitionWithOpacity
)
})
.onPreferenceChange(PathContextKey.self) { value in
Expand All @@ -450,19 +452,25 @@ public struct FlowLink<Label>: View where Label: View {

private func handleFlowLinkOpacity() {
if isShowing == true, buttonPressed {
isShowing = false
if configuration.transitionWithOpacity {
withAnimation(.easeOut) { isShowing = false }
} else {
isShowing = false
}
buttonPressed = false
} else if isShowing == false {
DispatchQueue.main.asyncAfter(deadline: .now() + flowDuration) { withAnimation(nil) {
isShowing = true
}}
if configuration.transitionWithOpacity {
withAnimation(.easeIn) { isShowing = true }
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + flowDuration) { isShowing = true }
}
}
}

private func initSnapshots() {
Task {
// Prevent Snapshot from being taken too early before Fetchable content loads
try? await Task.sleep(10000)
try? await Task.sleep(nanoseconds: 10_000)
let lightImage = createSnapshot(colorScheme: .light)
let darkImage = createSnapshot(colorScheme: .dark)
snapshots[.light] = lightImage
Expand All @@ -489,4 +497,15 @@ private extension View {
func ignoreAnimation(transition: AnyTransition = .identity) -> some View {
modifier(IgnoreAnimationModifier(transition: transition))
}

@ViewBuilder
func ifTransitionWithOpacity(_ condition: Bool) -> some View {
if condition {
self
} else {
/// (Workaround) Override an animation with an animation that does nothing
/// Leaving a flowlayer too early can cause an un-wanted animation
self.ignoreAnimation()
}
}
}
2 changes: 2 additions & 0 deletions Sources/FlowStack/FlowPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ struct PathContext: Equatable, Hashable {

var shouldShowSkrim: Bool = true
var shouldScaleHorizontally: Bool = true

var transitionWithOpacity: Bool = false
}

struct FlowElement: Equatable, Hashable {
Expand Down
5 changes: 2 additions & 3 deletions Sources/FlowStack/FlowStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -304,14 +304,13 @@ public struct FlowStack<Root: View, Overlay: View>: View {
.environment(\.flowDepth, 0)

ForEach(pathToUse.wrappedValue.elements, id: \.self) { element in
if let destination = destination(for: element.value) {

if let destination = destination(for: element.value) {
skrim(for: element)

destination.content(element.value)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.id(element.hashValue)
.transition(.flowTransition(with: element.context ?? .init()))
.transition(.flowTransition(with: element.context ?? .init(), transitionWithOpacity: element.context?.transitionWithOpacity ?? false))
.modifier(AccessibilityModifier(element: element.index))
}
}
Expand Down
39 changes: 32 additions & 7 deletions Sources/FlowStack/FlowTransition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ struct FlowDismissActionKey: EnvironmentKey {

extension AnyTransition {

static func flowTransition(with context: PathContext) -> AnyTransition {
static func flowTransition(with context: PathContext, transitionWithOpacity: Bool) -> AnyTransition {
AnyTransition.modifier(
active: FlowPresentModifier(percent: 0, context: context),
identity: FlowPresentModifier(percent: 1, context: context)
active: FlowPresentModifier(percent: 0, context: context, transitionWithOpacity: transitionWithOpacity),
identity: FlowPresentModifier(percent: 1, context: context, transitionWithOpacity: transitionWithOpacity)
)
}

Expand Down Expand Up @@ -72,6 +72,7 @@ extension AnyTransition {
struct FlowPresentModifier: Animatable, ViewModifier {
var percent: CGFloat
var context: PathContext
var transitionWithOpacity: Bool

@State var panOffset: CGPoint = .zero
@State var isEnded: Bool = false
Expand All @@ -80,6 +81,9 @@ extension AnyTransition {
@State private var snapCornerRadiusZero: Bool = true
@State private var availableSize: CGSize = .zero

@State var isPanDismiss: Bool = false
@State private var panOpacity: CGFloat = 1

private var snapshotPercent: CGFloat {
max(0, 1 - percent / 0.2)
}
Expand Down Expand Up @@ -160,20 +164,23 @@ extension AnyTransition {
GeometryReader { proxy in
let zoomRect = zoomRect(with: proxy, anchor: context.overrideAnchor ?? context.anchor, percent: percent, pullOffset: panOffset)
let scaleRatio = context.shouldScaleHorizontally ? zoomRect.size.width / proxy.size.width : 1.0

content
.onInteractiveDismissGesture(threshold: 80, isEnabled: !isDisabled, isDismissing: isDismissing, onDismiss: {
dismiss()
isDismissing = true
}, onPan: { offset in
}, onPan: { offset, shouldDismiss in
snapCornerRadiusZero = false
isEnded = false
panOffset = offset
if shouldDismiss, transitionWithOpacity { isPanDismiss = true }
}, onEnded: { isDismissing in
// TODO: FS-34: Handle snap corner radius 0 on interactive dismiss cancel
withTransaction(transaction) {
var customTransaction = transaction
customTransaction.animation?.speed(0.8)
withTransaction(customTransaction) {
panOffset = .zero
isEnded = true
if isPanDismiss { panOpacity = .zero }
}
})
.onPreferenceChange(InteractiveDismissDisabledKey.self) { isDisabled in
Expand Down Expand Up @@ -203,10 +210,28 @@ extension AnyTransition {
x: zoomRect.origin.x,
y: zoomRect.origin.y
)
.opacity(context.anchor == nil ? percent : 1)
.modifier(OpacityViewModifier(transitionWithOpacity: transitionWithOpacity, context: context, isDismissing: isDismissing, percent: percent, panOpacity: panOpacity))
}
.ignoresSafeArea(.container, edges: .all)
}

private struct OpacityViewModifier: ViewModifier {
let transitionWithOpacity: Bool
let context: PathContext
let isDismissing: Bool
var percent: CGFloat
var panOpacity: CGFloat
func body(content: Content) -> some View {
if !transitionWithOpacity {
content
.opacity(context.anchor == nil ? percent : 1)
} else {
content
.opacity(panOpacity)
.opacity(isDismissing ? 1 : percent)
}
}
}
}
}

Expand Down
12 changes: 6 additions & 6 deletions Sources/FlowStack/View+InteractiveDismiss.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ struct InteractiveDismissContainer<T: View>: UIViewControllerRepresentable {

var threshold: Double

var onPan: (CGPoint) -> Void
var onPan: (CGPoint, Bool) -> Void
var isEnabled: Bool
var isDismissing: Bool
var onDismiss: () -> Void
Expand Down Expand Up @@ -123,7 +123,7 @@ class InteractiveDismissViewController<Content: View>: UIHostingController<Conte
class InteractiveDismissCoordinator: NSObject, ObservableObject, UIGestureRecognizerDelegate {
var threshold: Double

var onPan: (CGPoint) -> Void
var onPan: (CGPoint, Bool) -> Void
var isEnabled: Bool {
didSet {
panGestureRecognizer.isEnabled = isEnabled
Expand Down Expand Up @@ -168,7 +168,7 @@ class InteractiveDismissCoordinator: NSObject, ObservableObject, UIGestureRecogn
}
}

init(threshold: Double, onPan: @escaping (CGPoint) -> Void, isEnabled: Bool, isDismissing: Bool, onDismiss: @escaping () -> Void, onEnded: @escaping (Bool) -> Void) {
init(threshold: Double, onPan: @escaping (CGPoint, Bool) -> Void, isEnabled: Bool, isDismissing: Bool, onDismiss: @escaping () -> Void, onEnded: @escaping (Bool) -> Void) {
self.threshold = threshold

self.onPan = onPan
Expand Down Expand Up @@ -210,13 +210,13 @@ class InteractiveDismissCoordinator: NSObject, ObservableObject, UIGestureRecogn
}

private func update(offset: CGPoint, isEdge: Bool, hasEnded: Bool) {
onPan(offset)

let shouldDismiss = offset.y > threshold || (offset.x > threshold && isEdge)
if shouldDismiss != isPastThreshold && shouldDismiss {
impactGenerator.impactOccurred()
}

onPan(offset, shouldDismiss)

isPastThreshold = shouldDismiss

if hasEnded {
Expand Down Expand Up @@ -274,7 +274,7 @@ class InteractiveDismissCoordinator: NSObject, ObservableObject, UIGestureRecogn
}

extension View {
func onInteractiveDismissGesture(threshold: Double = 50, isEnabled: Bool = true, isDismissing: Bool = false, onDismiss: @escaping () -> Void, onPan: @escaping (CGPoint) -> Void = { _ in }, onEnded: @escaping (Bool) -> Void = { _ in }) -> some View {
func onInteractiveDismissGesture(threshold: Double = 50, isEnabled: Bool = true, isDismissing: Bool = false, onDismiss: @escaping () -> Void, onPan: @escaping (CGPoint, Bool) -> Void = { _, _ in }, onEnded: @escaping (Bool) -> Void = { _ in }) -> some View {
InteractiveDismissContainer(threshold: threshold, onPan: onPan, isEnabled: isEnabled, isDismissing: isDismissing, onDismiss: onDismiss, onEnded: onEnded, content: self)
}
}