見出し画像

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)
    }
}
ZStackを使った表示

一見上手くいっているように見えますが、以下のようにNavigationBarが表示された状態にするとローディングViewがNavigationBarの下に配置されてしまい、ユーザーの操作をブロックすることができません。

NavigationBarが存在する状態でZStackを使用

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 #アプリ開発


この記事が気に入ったらサポートをしてみませんか?