Custom transition for fullScreenCover in SwiftUI


Greetings, traveler!

SwiftUI’s fullScreenCover modifier is a powerful tool for presenting views that take over the entire screen, such as modals, onboarding flows, or detailed content views. However, developers often want more control over the presentation animation—for example, how to make the full-screen cover fade in smoothly rather than relying on the default slide-up behavior.

In this article, we’ll walk through a solution that enables any custom transition for a full-screen cover using a custom view modifier. By the end, you’ll have a reusable tool to enhance your SwiftUI presentations with polished animations.

The Challenge with fullScreenCover

The fullScreenCover modifier in SwiftUI is straightforward to use: you bind it to a boolean value, and when that value becomes true, the specified content is presented over the entire screen. By default, it animates with a slide-up transition. However, if you want a different effect—like a fade-in using an opacity transition—SwiftUI doesn’t provide a built-in way to customize this directly within the fullScreenCover modifier.

This limitation prompts us to get creative. We need a way to apply a custom transition and animation to the content of the full-screen cover while still leveraging the modifier’s full-screen presentation capabilities.

The Solution: A Custom Full-Screen Cover Extension

To solve this, we’ll extend SwiftUI’s View protocol with a customFullScreenCover method. This method will use a ViewModifier called CustomFullScreenCoverModifier to handle the presentation logic, allowing us to specify both the transition and animation. Here’s the complete code:

import SwiftUI

public extension View {
    func customFullScreenCover<Content: View>(
        isPresented: Binding<Bool>,
        transition: AnyTransition = .opacity,
        animation: Animation = .easeInOut,
        @ViewBuilder content: @escaping () -> Content
    ) -> some View {
        
        modifier(
            CustomFullScreenCoverModifier(
                isPresented: isPresented,
                transition: transition,
                animation: animation,
                presentedView: content
            )
        )
    }
}

private struct CustomFullScreenCoverModifier<PresentedView: View>: ViewModifier {
    @Binding var isPresented: Bool
    let transition: AnyTransition
    let animation: Animation
    @ViewBuilder let presentedView: () -> PresentedView

    @State private var isPresentedInternal = false
    @State private var isShowContent = false
    @Environment(\.scenePhase) private var scenePhase
    @State private var isActive = true

    func body(content: Content) -> some View {
        content
            .fullScreenCover(isPresented: $isPresentedInternal) {
                Group {
                    if isShowContent {
                        presentedView()
                            .transition(transition)
                            .onDisappear {
                                guard scenePhase != .background, scenePhase != .inactive, isActive else { return }
                                isPresentedInternal = false
                                isPresented = false
                            }
                    }
                }
                .onAppear {
                    isShowContent = true
                }
                .presentationBackground(.clear)
            }
            .transaction {
                $0.disablesAnimations = true
                $0.animation = animation
            }
            .onChange(of: isPresented) { _, newValue in
                if newValue {
                    isPresentedInternal = true
                } else {
                    isShowContent = false
                }
            }
            .onChange(of: scenePhase) { _, newValue in
                switch newValue {
                case .background, .inactive:
                    isActive = false
                case .active:
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                        isActive = true
                    }
                @unknown default:
                    break
                }
            }
    }
}

Breaking Down the Code

Let’s explore how this solution works step by step:

CustomFullScreenCoverModifier:

  • CustomFullScreenCoverModifier manages the presentation logic using two @State variables:
    • isPresentedInternal: Controls the underlying fullScreenCover presentation.
    • isShowContent: Determines when to display the content with the specified transition.
  • Key Logic:
    • The fullScreenCover modifier is bound to isPresentedInternal.
    • Inside the full-screen cover, a Group conditionally shows the presentedView only when isShowContent is true. This allows the transition (e.g., fade-in) to apply to the content.
    • On appearance (onAppear), isShowContent is set to true, triggering the transition.
    • On disappearance (onDisappear), both isPresentedInternal and the external isPresented are reset to false.
    • The presentationBackground is set to .clear to ensure a transparent background.
    • A transaction disables default animations for fullScreenCover and applies the custom animation.
    • The onChange handler syncs the external isPresented binding:
      • When isPresented becomes true, isPresentedInternal is set to true to show the cover.
      • When isPresented becomes false, isShowContent is set to false to trigger the dismissal transition.
    • Checking the scenePhase allows us to prevent dismissing when View disappears.

This approach separates the full-screen cover’s presentation from the content’s transition, allowing us to animate the content as desired.

Example

Here’s an example of how to use customFullScreenCover in your SwiftUI app:

struct ContentView: View {
  @State private var isPresented = false
    
    var body: some View {
        Button("Show") {
            isPresented = true
        }
        .buttonStyle(.borderedProminent)
        .tint(.purple)
        .shadow(radius: 2)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(.orange)
        .customFullScreenCover(isPresented: $isPresented) {
            ChildView(isPresented: $isPresented)
        }
  }
}

struct ChildView: View {
  @Binding var isPresented: Bool

  var body: some View {
    VStack {
      Button("Dismiss") {
          isPresented = false
      }
      .buttonStyle(.borderedProminent)
      .tint(.orange)
      .shadow(radius: 2)
      .frame(maxWidth: .infinity, maxHeight: .infinity)
      .background(.purple)
    }
  }
}

Conclusion

Adding custom effects to a full-screen cover presentation can make transitions more interesting. Try experimenting with different transitions (like .scale or .asymmetric) and animations to find the perfect fit for your app’s design. By the way, you can integrate this code into your project via this Swift Package.