Skip to content

Instantly share code, notes, and snippets.

@shengchl
Created June 28, 2022 19:52
Show Gist options
  • Save shengchl/abfee38030e844897f1ef2de31353474 to your computer and use it in GitHub Desktop.
Save shengchl/abfee38030e844897f1ef2de31353474 to your computer and use it in GitHub Desktop.
SwiftUI transition modifier with advanced behavior
// 2022 (c) Alexey Grigorev, Ivan Oparin
// licensed under MIT
import SwiftUI
internal struct FixedTransactionTransition: ViewModifier {
@Binding var isPresentInParentContainer: Bool
@Binding var isPresentInBody: Bool
let transition: AnyTransition
let animation: Animation
func body(content: Content) -> some View {
VStack(spacing: 0) {
if isPresentInParentContainer, isPresentInBody {
content
.transaction {
$0.animation = animation
}
.transition(transition)
} else {
// keeps layout intact
content.opacity(0)
}
}
.onAppear {
isPresentInBody = true
}
}
}
public extension View {
/// A convinient way to apply transition to a view with advanced behavior that fixes certain edge cases (or bugs) of default SwiftUI transitions.
/// - Parameter transition: a transition associated with a state-processing update. Do not apply animation within **this** parameter.
/// - Parameter animation: animation associated with a transition. Overrides any other animation associated with a transaction.
/// - Parameter isPresentInBody: a toggle that will automatically trigger a transition on view appear. Must be associated with a hosting view (e.g. a State property on a view)
/// - Parameter isPresentInParentContainer: advanced toggle that must provide a context of the current state-processing update. See discussion for more info.
///
/// With the basic usage the modifier will just apply a provided transition with specified animation to a view.
/// When you have a complex view-hierarchy with a branched structure which switches depending on context, and you need to provide transitions to specific elements
/// withing the branch, SwiftUI will animate the transition of the whole branch and willl not propagate information about context switching deeper into hierarchy, i.e. views
/// will not know they were out of active hierachy and transitions will not occur.
///
/// To fix this behavior, switch the branch within an animation block and toggle a proxy propery that holds a boolean information about branch presence in active
/// view-hierarchy. It's important to switch branches within an animation block **and** pass the proxy as a binding, otherwise SwiftUI will lose the context associated with the transition
/// and it will not be applied properly.
///
/// @State private var branchAProxy = false
/// @State private var branchBProxy = false
/// var body: some View {
/// VStack(spacing: 0) {
/// switch currentBranch {
/// case .branchA:
/// ViewHierarchyA(isPresentInParentContainer: $branchAProxy)
/// case .branchB:
/// ViewHierarchyB(isPresentInParentContainer: $branchBProxy)
/// }
/// }
/// .onAppear {
/// setBranch(.branchA, animation: .linear)
/// }
/// }
///
/// private func setBranch(_ branch: Branch, animation: Animation) {
/// withAnimation(animation) {
/// self.currentBranch = branchpage
/// self.branchAProxy = (branch == .branchA)
/// self.branchBProxy = (page == .branchB)
/// }
/// }
///
/// // within ViewHierarchyA / ViewHierarchyB apply this modifier to individual elements
///
func transition(
_ transition: AnyTransition,
animation: Animation = .default,
isPresentInBody: Binding<Bool>,
isPresentInParentContainer: Binding<Bool> = .constant(true)
) -> some View {
self.modifier(
FixedTransactionTransition(
isPresentInParentContainer: isPresentInParentContainer,
isPresentInBody: isPresentInBody,
transition: transition,
animation: animation
)
)
}
}
@shengchl
Copy link
Author

shengchl commented Jun 28, 2022

import SwiftUI

struct ContentView: View {
    
    enum Branch {
        case branchA
        case branchB
    }
    
    @State private var currentBranch: Branch = .branchA
    @State private var branchAProxy = false
    @State private var branchBProxy = false
    
    var body: some View {
        VStack(spacing: 0) {
            switch currentBranch {
            case .branchA:
                BranchA(isVisibleInParentContainer: $branchAProxy, next: { next(.branchB) })
            case .branchB:
                BranchB(isVisibleInParentContainer: $branchBProxy, next: { next(.branchA) })
            }
        }
        .onAppear {
            withAnimation {
                next(.branchA)
            }
        }
        
    }
    
    private func next(_ branch: Branch) {
        withAnimation {
            currentBranch = branch
            branchAProxy = branch == .branchA
            branchBProxy = branch == .branchB
        }
    }
}

struct BranchA: View {
    @Binding var isVisibleInParentContainer: Bool
    @State private var elementInBranchAIsPresentInBody = false
    let next: () -> Void
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Button("What's up?") {
                next()
            }
        }
        .drawingGroup()  // a fix for a broken vstack animation on iOS 16 beta 2 simulator
        .transition(.move(edge: .leading), isPresentInBody: $elementInBranchAIsPresentInBody, isPresentInParentContainer: $isVisibleInParentContainer)
    }
}

struct BranchB: View {
    @Binding var isVisibleInParentContainer: Bool
    @State private var elementInBranchBisPresentInBody = false
    let next: () -> Void
    var body: some View {
        VStack {
            Image(systemName: "hand.thumbsup.fill")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Button("Not too much") {
                next()
            }
        }
        .drawingGroup()  // a fix for a broken vstack animation on iOS 16 beta 2 simulator
        .transition(.move(edge: .trailing), animation: .linear, isPresentInBody: $elementInBranchBisPresentInBody, isPresentInParentContainer: $isVisibleInParentContainer)
    }
}

@shengchl
Copy link
Author

updated version with internal toggle moved to view-modifier

// 2022 (c) Alexey Grigorev and Ivan Oparin
// licensed under MIT

import SwiftUI

internal struct FixedTransactionTransition: ViewModifier {
    
    @Binding var isPresentInParentContainer: Bool
    
    @State private var isPresentInBody = false
    
    let transition: AnyTransition
    let animation: Animation
    
    func body(content: Content) -> some View {
        VStack(spacing: 0) {
            if isPresentInParentContainer, isPresentInBody {
                content
                    .transaction {
                        $0.animation = animation
                    }
                    .transition(transition)
            } else {
                // keeps layout intact
                content.opacity(0)
            }
        }
        .onAppear {
            isPresentInBody = true
        }
    }
}

public extension View {
    
    /// A convinient way to apply transition to a view with advanced behavior that fixes certain edge cases (or bugs) of default SwiftUI transitions.
    /// - Parameter transition: a transition associated with a state-processing update. Do not apply animation within **this** parameter.
    /// - Parameter withAnimation: animation associated with a transition. Overrides any other animation associated with a transaction.
    /// - Parameter isPresentInParentContainer: advanced toggle that must provide a context of the current state-processing update. See discussion for more info.
    ///
    /// With the basic usage the modifier will just apply a provided transition with specified animation to a view.
    /// When you have a complex view-hierarchy with a branched structure which switches depending on context, and you need to provide transitions to specific elements
    /// withing the branch, SwiftUI will animate the transition of the whole branch and willl not propagate information about context switching deeper into hierarchy, i.e. views
    /// will not know they were out of active hierachy and transitions will not occur.
    ///
    /// To fix this behavior, switch the branch within an animation block and toggle a proxy propery that holds a boolean information about branch presence in active
    /// view-hierarchy. It's important to switch branches within an animation block **and** pass the proxy as a binding, otherwise SwiftUI will lose the context associated with the transition
    /// and it will not be applied properly.
    ///
    ///     @State private var branchAProxy = false
    ///     @State private var branchBProxy = false
    ///     var body: some View {
    ///         VStack(spacing: 0) {
    ///             switch currentBranch {
    ///             case .branchA:
    ///                 ViewHierarchyA(isPresentInParentContainer: $branchAProxy)
    ///             case .branchB:
    ///                 ViewHierarchyB(isPresentInParentContainer: $branchBProxy)
    ///             }
    ///         }
    ///         .onAppear {
    ///             setBranch(.branchA, animation: .linear)
    ///         }
    ///     }
    ///
    ///     private func setBranch(_ branch: Branch, animation: Animation) {
    ///         withAnimation(animation) {
    ///             self.currentBranch = branchpage
    ///             self.branchAProxy = (branch == .branchA)
    ///             self.branchBProxy = (page == .branchB)
    ///         }
    ///      }
    ///
    ///      // within ViewHierarchyA / ViewHierarchyB apply this modifier to individual elements
    ///
    func transition(
        _ transition: AnyTransition,
        withAnimation animation: Animation = .default,
        isPresentInParentContainer : Binding<Bool> = .constant(true)
    ) -> some View {
        self.modifier(
            FixedTransactionTransition(
                isPresentInParentContainer: isPresentInParentContainer,
                transition: transition,
                animation: animation
            )
        )
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment