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 を設定しています。
TransactionModifier は transaction(value:_:) が呼ばれた際にTransactionのdisablesAnimationsに isPresented の値を入れています。
TransactionのdisablesAnimationsを false にすることでデフォルトのトランジションアニメーションを消せるのですが、 isPresented が true の時 (表示時) のみ無効にしているのが肝です。
表示時のアニメーションを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を表示しているPresentationHostingController の modalTransitionStyle を crossDissolve に上書きすることで対応を行います。
「非表示時も transform.disablesAnimations を true に設定してデフォルトのトランジションアニメーションを消して、SwiftUIのAnimationで実現すればよいのでは...?」と思われるかもしれませんが、非表示時に transform.disablesAnimations を true にした場合、トランジションアニメーション無しですぐ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 を見つけて、 modalTransitionStyle を crossDissolve にしてやることで、フルモーダルを閉じる時に crossDissolve なアニメーションが実現できるようにしています。