SwiftUIで画面全体を覆うようにローディングViewを表示させる
はじめに
くふうAIスタジオで iOS版の Zaim アプリの開発を担当している TEM です。
Zaimでは現在SwiftUIの活用を進めているのですが、その過程でローディング画面を実装した際に、苦労した点について書きたいと思います。
ローディングViewを画面全体を覆うように表示させたい
APIの呼び出し時や、重い計算処理をしている際にユーザーの操作をブロックさせたいケースはよく発生すると思います。UIKitベースの開発では最前面に表示されているViewControllerに対して任意のViewを配置することで実現することができましたが、SwiftUIだと工夫が必要です。
ZStackを使った実装
SwiftUIで最前面にViewを配置したいとき、まずZStackを使った実装が思い浮かぶと思います。
struct LoadingZStackView: View {
var body: some View {
ZStack {
LoadingView()
Text("Hello World")
}.ignoresSafeArea(.all)
}
}
一見上手くいっているように見えますが、以下のようにNavigationBarが表示された状態にするとローディングViewがNavigationBarの下に配置されてしまい、ユーザーの操作をブロックすることができません。
fullScreenCoverを使ってみる
表示中のViewへの操作をブロックするために fullScreenCover を使って、前面に全画面表示のViewを配置することで上手くいかないか試してみました。
Text("Hello World")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.navigationTitle("Loading")
.fullScreenCover(isPresented: .constant(true)) {
LoadingView().ignoresSafeArea(.all)
}
ユーザー操作自体は完全にブロックできているのですが、fullScreenCoverでモーダル表示したViewのせいで元の画面が表示できていません。この状態だとあまり見栄えが良くないので、どうにか元の画面を表示できないかと模索していたところ以下のページを見つけました。このページによると以下のようなUIViewRepresentableを作り、UIView側から強引に要素を辿ることでモーダル表示したシート自体の背景を透明にできるようです。
struct SheetBackgroundClearView: UIViewRepresentable {
func makeUIView(context _: Context) -> UIView {
let view = SuperviewRecolourView()
return view
}
func updateUIView(_: UIView, context _: Context) {}
}
class SuperviewRecolourView: UIView {
override func layoutSubviews() {
guard let parentView = superview?.superview else {
return
}
parentView.backgroundColor = .clear
}
}
試しにやってみたところ以下のように綺麗に表示されるようになりました!
使いやすくなるように整理
今のままだと使いにくいので、ViewModiferとして作成し利用しやすくなるようにしてみました。またアニメーション周りの調整も入れています。
extension View {
func loading(_ state: LoadingViewState) -> some View {
modifier(LoadingViewModifier(loadingState: state))
}
}
struct LoadingViewModifier: ViewModifier {
@ObservedObject var loadingState: LoadingViewState
func body(content: Content) -> some View {
content.fullScreenCover(isPresented: $loadingState.isPresented) {
loadingView()
.ignoresSafeArea(.all)
.background(SheetBackgroundClearView())
}
}
private func loadingView() -> some View {
ZStack {
Color(UIColor(red: 0.2, green: 0.2, blue: 0.2, alpha: 0.75))
VStack(alignment: .center, spacing: 40) {
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
.scaleEffect(x: 1.8, y: 1.8, anchor: .center)
.frame(width: 1.8 * 20, height: 1.8 * 20)
.padding(.top, 100)
Text(loadingState.message)
.font(.body)
.foregroundColor(.white)
.padding(.horizontal, 20)
.frame(maxWidth: 300)
Spacer()
}
}
.opacity(loadingState.isLoading ? 1.0 : 0)
}
}
@MainActor final class LoadingViewState: ObservableObject {
@Published var isPresented: Bool = false
@Published var isLoading: Bool = false
@Published var message: String = ""
private let easeInDuration = 0.5
private var animationStartDate: Date?
private var task: Task<Void, Error>?
/// LoadingViewを表示
func show(_ messageType: MessageType = .loading, after: Double = 0.0) {
if !isPresented {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
self.isPresented = true
}
}
task?.cancel()
animationStartDate = nil
task = Task.detached { @MainActor in
try await Task.sleep(nanoseconds: after.toNanoseconds())
self.message = messageType.message
withAnimation(.easeIn(duration: self.easeInDuration)) {
self.isLoading = true
}
self.animationStartDate = Date()
}
}
/// LoadingViewを閉じる
func hide(secondsToBlock: Double = 0.1) async {
task?.cancel()
// 表示アニメーションが完了していなかったら終わるまで待機させる
if didStartAnimation {
await waitForShowAnimationFinishIfNeeded()
} else {
await closeSheet(secondsToBlock)
return
}
// 非表示アニメーションが完了してから、シートを閉じる
withAnimation(.easeIn(duration: 0.4)) {
self.isLoading = false
}
try? await Task.sleep(nanoseconds: 0.6.toNanoseconds())
await closeSheet(secondsToBlock)
}
func showAndHide(_ messageType: MessageType = .loading, hideAfter: Double = 3.0) async {
show(messageType, after: 0.1)
try? await Task.sleep(nanoseconds: hideAfter.toNanoseconds())
await hide()
}
func showErrorAndHide(_ error: Error, hideAfter: Double = 3.0) async {
await showAndHide(.anyError(error), hideAfter: hideAfter)
}
}
extension LoadingViewState {
// シートをアニメーションなしで閉じる
private func closeSheet(_ secondsToBlock: Double) async {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
self.isPresented = false
}
message = ""
animationStartDate = nil
// ローディングViewを閉じた直後に遷移を行うと処理がブロックされて、
// 動かないので遅延を入れる
try? await Task.sleep(nanoseconds: secondsToBlock.toNanoseconds())
}
// 表示アニメーションが完了していなかったら終わるまで待機させる
private func waitForShowAnimationFinishIfNeeded() async {
guard let elapsed = showAnimationElapsedTime else {
return
}
let waitTime = easeInDuration - elapsed
if waitTime > 0 {
try? await Task.sleep(nanoseconds: waitTime.toNanoseconds())
}
}
/// 表示アニメーションが開始されたか
private var didStartAnimation: Bool {
return animationStartDate != nil
}
/// 表示アニメーション開始からの経過時間
private var showAnimationElapsedTime: Double? {
guard let animationStartDate else {
return nil
}
let start = animationStartDate.timeIntervalSince1970
let current = Date().timeIntervalSince1970
let elapse = current - start
return elapse
}
}
extension LoadingViewState {
enum MessageType {
/// 表示データの取得、同期時
case loading
// 省略
var message: String {
return "Loading"
}
}
}
struct SheetBackgroundClearView: UIViewRepresentable {
func makeUIView(context _: Context) -> UIView {
let view = SuperviewRecolourView()
return view
}
func updateUIView(_: UIView, context _: Context) {}
}
class SuperviewRecolourView: UIView {
override func layoutSubviews() {
guard let parentView = superview?.superview else {
return
}
parentView.backgroundColor = .clear
}
}
使用例
@MainActor final class ViewModel: ObservableObject {
@Published var loadingState = LoadingViewState()
init() {
Task {
await loadingState.showAndHide()
}
}
}
struct LoadingContentView: View {
@StateObject var vm = ViewModel()
var body: some View {
Text("Hello World")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.navigationTitle("Loading")
.toolbarBackground(Color.orange, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.loading(vm.loadingState)
}
}
まとめ
SwiftUIを使ってローディングViewを作成してみました。様々なシチュエーションで使える内容になっていると思うので、ぜひ参考にしてみてください。
くふうAIスタジオでは、採用活動を行っています。
当社は「AX で 暮らしに ひらめきを」をビジョンに、2023年7月に設立されました。
(AX=AI eXperience(UI/UX における AI/AX)とAI Transformation(DX におけるAX)の意味を持つ当社が唱えた造語)
くふうカンパニーグループのサービスの企画開発運用を主な事業とし、非エンジニアさえも当たり前に AI を使いこなせるよう、積極的な AI 利活用を推進しています。
(サービスの一例:累計 DL 数 1,000 万以上の家計簿アプリ「Zaim」、月間利用者数 1,600 万人のチラシアプリ「トクバイ」等)
AX を活用した未来を一緒に作っていく仲間を募集中です。
ご興味がございましたら、以下からカジュアル面談のお申込みやご応募等お気軽にお問合せください。
#中途採用 #エンジニア採用 #採用広報 #株式会社くふうAIスタジオ #テックブログ #swift #iOS #アプリ開発