2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUIでcrossDissolveアニメーション付きの背景透過なフルモーダルをfullScreenCoverを用いて実現する

Last updated at Posted at 2025-06-04

SwiftUIでcrossDissolveアニメーション付きの背景透過なフルモーダルをfullScreenCoverを用いて実現する

上のGifのように、階層構造を持ったsheetやpopoverの上からも開くことができる、背景透過なフルモーダルをSwiftUIの fullScreenCover ベースで実装したので、その方法について紹介します。

コード全体

import SwiftUI
import UIKit

private struct ModalCover<Content: View>: View {
    @State private var backgroundOpacity: CGFloat = 0.0
    @State private var contentOpacity: CGFloat = 0.0
    @State private var contentScale: CGFloat = 1.2
    @State private var isAnimationCompleted: Bool = false

    var isPresented: Binding<Bool>
    var content: Content

    init(isPresented: Binding<Bool>, @ViewBuilder content: () -> Content){
        self.isPresented = isPresented
        self.content = content()
     }

    var body: some View {
        ZStack {
            // 背景のタップエリア
            Color.clear
                .contentShape(Rectangle())
                .ignoresSafeArea(.all)
                .onTapGesture {
                    if isAnimationCompleted {
                        isPresented.wrappedValue = false
                    }
                }

            // モーダルコンテンツ
            content
                .opacity(contentOpacity)
                .scaleEffect(contentScale)
        }
        .background(CrossDissolveOverride()) // 閉じる時のアニメーションはCrossDissolveOverrideに任せる
        .presentationBackground(Color.black.opacity(backgroundOpacity))
        .onAppear {
            // 開いた時のアニメーション
            withAnimation(.easeInOut(duration: 0.2), completionCriteria: .logicallyComplete, {
                backgroundOpacity = 0.2
                contentOpacity = 1.0
                contentScale = 1.0
            }, completion: {
                isAnimationCompleted = true
            })
        }
    }

    struct CrossDissolveOverride: UIViewRepresentable {
        func makeUIView(context: Context) -> UIView {
            let view = UIView()
            // SwiftUI.PresentationHostingController を取り出す
            DispatchQueue.main.async {
                var responder: UIResponder? = view.superview?.next
                while responder != nil {
                    guard let responder, let vc = responder as? UIViewController else {
                        responder = responder?.next
                        continue
                    }
                    // viewが紐づいているPresentationHostingControllerのmodalTransitionStyleをcrossDissolveにしておくことで、fullScreenCoverを閉じるときのアニメーションを標準のslideから変更できる
                    vc.modalTransitionStyle = .crossDissolve
                    break
                }
            }
            return view
        }

        func updateUIView(_ uiView: UIView, context: Context) {}
    }
}

// ViewModifierにしないとsheet/popoverから開いた際にtransaction(value:)の前にfullScreenCoverのslideInアニメーションが始まってしまう
private struct TransactionModifier: ViewModifier {
    var isPresented: Binding<Bool>

    func body(content: Content) -> some View {
        content
            .transaction(value: isPresented.wrappedValue) { transform in
                // 開かれるタイミングの時だけisPresentedを無効化する (トランジションのアニメーションではなくSwiftUI.Viewのアニメーションを利用する)
                transform.disablesAnimations = isPresented.wrappedValue
            }
    }
}

public extension View {
    func fullScreenModal(isPresented: Binding<Bool>, content: @escaping () -> some View) -> some View {
        fullScreenCover(isPresented: isPresented) {
            ModalCover(isPresented: isPresented, content: content)
        }
        .modifier(TransactionModifier(isPresented: isPresented))
    }
}

使い方

struct ContentView: View {
    @State private var isPresented: Bool = false
    var body: some View {
        Button(action: {
            isPresented = true
        }, label: {
            Text("カスタムモーダルを開く")
        })
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        .fullScreenModal(isPresented: $isPresented) {
            Text("モーダル")
                .frame(width: 300, height: 300)
                .background(Color.white)
                .clipShape(RoundedRectangle(cornerRadius: 16))
                .padding(.horizontal, 20)
        }
    }
}

fullScreenCover と同じような使用感で背景透過なフルモーダルが表示できます。

コードの説明

通常の fullScreenCover だと、スクリーンサイズより小さいViewを fullScreenCover に指定しても白背景が表示されてしまいます。
また、スライドイン / スライドアウトアニメーションが表示されてしまいます。

Transitionアニメーションを消す

// ViewModifierにしないとsheet/popoverから開いた際にtransaction(value:)の前にfullScreenCoverのslideInアニメーションが始まってしまう
private struct TransactionModifier: ViewModifier {
    var isPresented: Binding<Bool>

    func body(content: Content) -> some View {
        content
            .transaction(value: isPresented.wrappedValue) { transform in
                // 開かれるタイミングの時だけisPresentedを無効化する (トランジションのアニメーションではなくSwiftUI.Viewのアニメーションを利用する)
                transform.disablesAnimations = isPresented.wrappedValue
            }
    }
}

public extension View {
    func fullScreenModal(isPresented: Binding<Bool>, content: @escaping () -> some View) -> some View {
        fullScreenCover(isPresented: isPresented) {
            ModalCover(isPresented: isPresented, content: content)
        }
        .modifier(TransactionModifier(isPresented: isPresented))
    }
}

Viewのextensionとして用意した fullScreenModal では fullScreenCover のコンテンツとして ModalCover を指定しています。また modifier として TransactionModifier を設定しています。

TransactionModifiertransaction(value:_:) が呼ばれた際にTransactionのdisablesAnimationsに isPresented の値を入れています。

TransactionのdisablesAnimationsを false にすることでデフォルトのトランジションアニメーションを消せるのですが、 isPresentedtrue の時 (表示時) のみ無効にしているのが肝です。

表示時のアニメーションをSwiftUIのView側で行う

先ほどデフォルトのトランジションをオフにしました。ModalCoverでは onAppear のタイミングで withAnimation を呼び出し、開いた時にフェードインアニメーションがされるように実装を行います。

private struct ModalCover<Content: View>: View {
    @State private var backgroundOpacity: CGFloat = 0.0
    @State private var contentOpacity: CGFloat = 0.0
    @State private var contentScale: CGFloat = 1.2
    @State private var isAnimationCompleted: Bool = false

    var isPresented: Binding<Bool>
    var content: Content

    init(isPresented: Binding<Bool>, @ViewBuilder content: () -> Content){
        self.isPresented = isPresented
        self.content = content()
     }

    var body: some View {
        ZStack {
            // 背景のタップエリア
            Color.clear
                .contentShape(Rectangle())
                .ignoresSafeArea(.all)
                .onTapGesture {
                    if isAnimationCompleted {
                        isPresented.wrappedValue = false
                    }
                }

            // モーダルコンテンツ
            content
                .opacity(contentOpacity)
                .scaleEffect(contentScale)
        }
        .background(CrossDissolveOverride()) // 閉じる時のアニメーションはCrossDissolveOverrideに任せる
        .presentationBackground(Color.black.opacity(backgroundOpacity))
        .onAppear {
            // 開いた時のアニメーション
            withAnimation(.easeInOut(duration: 0.2), completionCriteria: .logicallyComplete, {
                backgroundOpacity = 0.2
                contentOpacity = 1.0
                contentScale = 1.0
            }, completion: {
                isAnimationCompleted = true
            })
        }
    }
}

非表示時のアニメーションにcrossDissolveを設定する

表示時のアニメーションはSwiftUIのViewで行いましたが、非表示時はSwiftUIのViewを表示しているPresentationHostingControllermodalTransitionStylecrossDissolve に上書きすることで対応を行います。

「非表示時も transform.disablesAnimationstrue に設定してデフォルトのトランジションアニメーションを消して、SwiftUIのAnimationで実現すればよいのでは...?」と思われるかもしれませんが、非表示時に transform.disablesAnimationstrue にした場合、トランジションアニメーション無しですぐViewが閉じてしまうため、 onDisappear などのタイミングで withAnimation を発火させても、Viewのアニメーションがされないままモーダルを表示しているViewをホストしている PresentationHostingController がdismissされてしまうため、非表示時はトランジションのアニメーションを利用するようにしています。

private struct ModalCover<Content: View>: View {
    // ... 略

    var body: some View {
        ZStack {
            // ... 略
        }
        .background(CrossDissolveOverride()) // 閉じる時のアニメーションはCrossDissolveOverrideに任せる
        .presentationBackground(Color.black.opacity(backgroundOpacity))
        .onAppear {
            // ... 略
        }
    }

    struct CrossDissolveOverride: UIViewRepresentable {
        func makeUIView(context: Context) -> UIView {
            let view = UIView()
            // SwiftUI.PresentationHostingController を取り出す
            DispatchQueue.main.async {
                var responder: UIResponder? = view.superview?.next
                while responder != nil {
                    guard let responder, let vc = responder as? UIViewController else {
                        responder = responder?.next
                        continue
                    }
                    // viewが紐づいているPresentationHostingControllerのmodalTransitionStyleをcrossDissolveにしておくことで、fullScreenCoverを閉じるときのアニメーションを標準のslideから変更できる
                    vc.modalTransitionStyle = .crossDissolve
                    break
                }
            }
            return view
        }

        func updateUIView(_ uiView: UIView, context: Context) {}
    }
}

CrossDissolveOverride というstructは UIViewRepresentable を用いてSwiftUIのViewからUIKitのUIViewへアクセスできるようにしています。

fullScreenCover によって表示されたSwiftUIのViewは、内部ではSwiftUI._UIHostingView<SwiftUI.AnyView> というUIViewのクラスにラップされSwiftUI.PresentationHostingController というUIViewControllerが表示しています。

fullScreenCover ではこの SwiftUI.PresentationHostingController をモーダル表示しているため、UIViewReporesentable を用いてUIKitの世界からresponder chainを辿って、  SwiftUI.PresentationHostingController を見つけて、 modalTransitionStylecrossDissolve にしてやることで、フルモーダルを閉じる時に crossDissolve なアニメーションが実現できるようにしています。

2
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?