今回のブログでは、SwiftUIの iOS13の特定のマイナーバージョンにおいて、onDisappearが呼ばれなくてハマったので、それの解決方法をまとめてみました。

はじめに

 

こんにちは! iOSエンジニアのしゅんたです。

早いもので、入社して5ヶ月が経過しようとしており、今ではSwiftはもちろんのこと、SwiftUIを使用した開発にも挑戦しています。

 

「SwiftUI」とは2019年6月にAppleによって発表されたiPhoneなどのAppleプラットフォームのユーザーインターフェースを、従来よりも簡単に構築できるUIフレームワークのことです。


しかし、2019年6月に発表されたこともあって、SwiftUIに関する情報が少なく、予期せぬ不具合が起こってしまうことが懸念されています。

その知見の少なさから積極的にプロジェクトに取り入れようとする所は、現段階ではまだ少ないのではないでしょうか。

 

私がSwiftUIを触ってみて、一番ハマったポイントは iOS13の特定のマイナーバージョンにおいての動作不具合でした。

実際、iOSアプリの新規プロジェクトを考えるにあたっても、まだiOS13.0以降をサポートバージョンに含めるという所は多いと思います。

その中で、iOS13の特定のマイナーバージョンだけ処理が実行できない(泣)っていうことが多々ありましたので、今回はその内容の一部を記事にさせていただきます。

 

是非、最後までご覧ください!

 

 

取り挙げる内容

  • onDisappearとは?
  • 検証準備
  • iOS13.0でonDisappearが呼ばれない(泣)
  • 解決策
  • まとめ

 

 

 

onDisappearとは?

 

まずは、本稿の主役であるonDisappearについて説明します。

onDisappearとは、対象のViewが消えた時にクロージャ内の指定されたアクションを実行します。

 

UIkitでは下記のコードと同じような使い方をします。

    
func viewDidDisappear(animated: Bool)
    

では、早速どのような動きになるか見ていきましょう。

 

 

 

検証準備

 

今回は、対象となるViewが消えた時に、別のViewのテキストを更新するという実装を行なっていきます。

 

 

 

まず、TopViewModelを準備します。

    
class TopViewModel: ObservableObject {
    @Published var programmingLanguage = "Swift"
    
    func updatedName(language: String) {
        programmingLanguage = language
    }
}
    

ObservableObjectを付けて宣言されたデータクラスは、SwiftUIの監視対象となり、 @Publishedの属性が付与されているプロパティが更新されると参照しているViewが自動的に更新されます。

 

 

 

続いて、TopViewを実装します。

    
struct TopView: View {
    @ObservedObject var viewModel: TopViewModel
    
    var body: some View {
        NavigationView {
            VStack(alignment: .center) {
                Spacer()
                Text(".onDisappearの検証").font(.title)
                Text(self.viewModel.programmingLanguage).font(.largeTitle)
                    .padding()
                NavigationLink("入力画面へ", destination: InputLanguageView(viewModel: self.viewModel))
                Spacer(minLength: 300)
            }
        }
    }
}
    

@ObservedObjectを付与して、TopViewModelを継承し、TopViewModelが持っているprogrammingLanguageのデータをViewに表示させます。 そして、NavigationLinkを使用して、入力画面に遷移させる実装をします。

 

 

 

最後は入力画面の実装です。

    
struct InputLanguageView: View {
    @ObservedObject var viewModel: TopViewModel
    @State var newLanguage = ""
    
    var body: some View {
        VStack(alignment: .center) {
            Text("テキストを入力する").font(.title)
            TextField(viewModel.programmingLanguage, text: $newLanguage)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                        .padding()
            Spacer()
        }
        .onDisappear {
            viewModel.updatedName(language: self.newLanguage)
        }
    }
}
    

先程と同じように、TopViewModelを継承し、programmingLanguageのデータをTextFieldに表示させ、入力される値を監視するために、@Stateを付与したプロパティを用意します。

そして、onDisappearを使用しInputLanguageViewが消えた時に、TextFieldに入力されている文字をTopViewに伝える処理を書きます。

 

以上が検証準備となります。 ビルドをすると下記の画像のようになると思います。

 

Untitled①.png

 

 

 

iOS13.0でonDisappearが呼ばれない(泣)

 

まずは、iOS14.5で動きを見ていきましょう。

最初、トップ画面に表示されている文字は「Swift」と表示されています。

入力画面に遷移して、今回は「PHP」と入力して、Backボタンを押します。すると、入力画面が消えるタイミングでトップ画面のテキストを上書きするメソッドが呼ばれ、トップ画面のテキストが更新されます。

 

Untitled.png

 

 

 

いいですね! ちゃんと実装できています。

 

では、続いてiOS13.0で検証してみましょう。

先程と同様に「PHP」と入力してみます。すると、どうでしょう。先程はテキストが更新されたのにiOS13.0ではテキストが更新されません

これは、どういうことでしょうか。

 

実装前iOS13.png

 

原因を調査してみます。

onDisappearのクロージャ内でprintを出力してみましょう。するとiOS13.0ではそもそもonDisappearが呼ばれていないことがわかります。

    
struct InputLanguageView: View {
    @ObservedObject var viewModel: TopViewModel
    @State var newLanguage = ""
    
    var body: some View {
        VStack(alignment: .center) {
            Text("テキストを入力する").font(.title)
            TextField(viewModel.programmingLanguage, text: $newLanguage)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                        .padding()
            Spacer()
        }
        .onDisappear {
            print(呼ばれています)
            viewModel.updatedName(language: self.newLanguage)
        }
    }
}
    

 

出力結果

    
iOS14.5 
呼ばれています
iOS13.0 
// printの出力がない
    

 

 

 

解決策

 

改めて言うと、iOS13.0ではonDisappearが呼ばれません。そして、解決策の結論を言うと、UIKitのfunc viewDidDisappear(animated: Bool)をSwiftUIでも使用できるようにします。

では、実装内容をみていきましょう!

 

まず、viewDidDisappear時にonDisapperを呼ぶためのハンドラを用意します。

    
// ①
struct ViewDidDisappearHandler: UIViewControllerRepresentable {
    let onDisappear: () -> Void

    // ②
    internal func makeCoordinator() -> ViewDidDisappearHandler.Coordinator {
        Coordinator(onDisappear: onDisappear)
    }
    
    internal func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIViewController {
        context.coordinator
    }
    
    internal func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext) {}
    
    // ③
    final class Coordinator: UIViewController {
        let onDisappear: () -> Void
        
        init(onDisappear: @escaping () -> Void) {
            self.onDisappear = onDisappear
            super.init(nibName: nil, bundle: nil)
        }
        
        @available(*, unavailable)
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func viewDidDisappear(_ animated: Bool) {
            super.viewDidDisappear(animated)
            onDisappear()
        }
    }
}

// ④
struct ViewDidDisappearModifier: ViewModifier {
    let callback: () -> Void
    
    func body(content: Content) -> some View {
        content.background(ViewDidDisappearHandler(onDisappear: callback))
    }
}
   

 

 

では、一つずつ解説していきます! (以下、上記の① ~ ④ごとに説明します)

 

    
struct ViewDidDisappearHandler: UIViewControllerRepresentable{}
    

UIViewControllerRepresentableは、UIViewControllerをSwiftUIで使用するために必要です。

 

 

    
internal func makeCoordinator() -> ViewDidDisappearHandler.Coordinator {
    Coordinator(onDisappear: onDisappear)
}

internal func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIViewController {
    context.coordinator
}
    

UIViewControllerRepresentableContextは、UIKitのViewControllerを作成・更新する際に使用します。

 

    
internal func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext) {}
    

今回、updateUIViewControllerメソッドは使用しませんが、記述をしないとエラーになるので書きます。

 

 

続いて、SwiftUIにUIkitのイベントを伝えるCoordinatorクラスを作成します。

    
final class Coordinator: UIViewController {
        let onDisappear: () -> Void
        
        init(onDisappear: @escaping () -> Void) {
            self.onDisappear = onDisappear
            // UIViewControllerが持つinitを呼び出します
            super.init(nibName: nil, bundle: nil)
            
        }

       // UIViewControllerのサブクラスで独自のイニシャライザを用意した場合、記述が必要です
        @available(*, unavailable)
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func viewDidDisappear(_ animated: Bool) {
            super.viewDidDisappear(animated)
            onDisappear()
        }
    }
    

 

各処理の内容は上記の通りで、ポイントとしてはUIViewControllerクラスのviewDidDisappearをここで呼び、プロパティで定義したonDisappearを呼んであげます。

 

 

最後に、SwiftUIのViewModifirer を使うとViewのカスタマイズができるので、ViewDidDisappearHandler(onDisappear: callback)をSwiftUIのViewの背後に配置します。

 

    
struct ViewDidDisappearModifier: ViewModifier {
    let callback: () -> Void
    
    func body(content: Content) -> some View {
        content.background(ViewDidDisappearHandler(onDisappear: callback))
    }
}
    

 

 

以上がonDisapperを呼ぶためのハンドラの解説です。

 

では、これを実際に使っていきましょう。

今回は、InputLanguageViewのみ使用したいので、private extensionとして記述します。

 

すると、先程定義したUIkitのviewDidDisappearが呼ばれた時の処理をonViewDisappear()としてsome View内で使用できるようになります。(今回は分かりやすくするためにonViewDisappear()としていますが、本来であれば、onDisappearと記述した方が良いでしょう)

    
private extension View {
    func onViewDisappear(_ perform: @escaping (() -> Void)) -> some View {
        modifier(ViewDidDisappearModifier(callback: perform))
    }
}
    

 

蛇足かもしれませんが、実装後のコードは以下の通りです。

 

    
struct InputLanguageView: View {
    @ObservedObject var viewModel: TopViewModel
    @State var newLanguage = ""
    
    var body: some View {
        VStack(alignment: .center) {
            Text("テキストを入力する").font(.title)
            TextField(viewModel.programmingLanguage, text: $newLanguage)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                        .padding()
            Spacer()
        }
// ここに書く
        .onViewDisappear {
            print("呼ばれています")
            viewModel.updatedName(language: self.newLanguage)
        }
    }
}

//  onViewDisappearメソッドを記述します
private extension View {
    func onViewDisappear(_ perform: @escaping (() -> Void)) -> some View {
        modifier(ViewDidDisappearModifier(callback: perform))
    }
}

    

では、動作確認をしてみましょう。

iOS13.0でもテキストが更新され、.onViewDisappearが呼ばれていることを確認することができました。

 

 

実装後iOS13.0.png

 

 

出力結果

    
iOS13.0 
呼ばれています
    

もちろん、iOS14.5の場合でも問題なく動作します。

 

 

 

まとめ

今回は、 iOS13の特定のマイナーバージョンにおいてonDisappearが呼ばれない話ついて書かせていただきました。

この不具合を調査するにあたって、iOS13.0以外にも検証したマイナーバージョンは以下の通りです。(iOS13.8、iOS13.9は現在のXcodeではマイナーバージョンをダウンロードできないため、今回は検証していません)

 

iOS13.1

iOS13.2

iOS13.3

iOS13.4

iOS13.5

iOS13.6

iOS13.7

 

結論、iOS13.1以降は問題なく動作します。

しかし、iOS13の特定のマイナーバージョンにおいての不具合は今回紹介したものだけではありません。私が経験した不具合の中にはテキストを2行以上にわたって表示したい時に使うlineLimitやCombineのonReceiveが実行されなかったりとiOS13.0に加え、13.1も正常な動作をしない実装もありました。

ちなみに余談ですが、onDisappearと対局の存在であるonAppear(Viewが表示されたときにアクションを実行するメソッド)は、上記のiOS13のマイナーバージョンにおいてonAppearが呼ばれないという不具合はありませんでした。

 

私は今回のことを踏まえて、SwiftUIをプロジェクトに導入する際には、最低でも以下のことを念頭において導入を決めるべきだと考えています。

 

サポートバージョンにiOS13は含めるか、含めないか

② iOS13の特定のマイナーバージョンをサポートから外す

③ iOS13の特定のマイナーバージョンの不具合情報を集め、対策を考えてから導入する

 

これらの問題さえ突破すれば、SwiftUIは非常に使いやすいフレームワークなので、積極的にプロジェクトに取り入れるべきではないかと私は考えています。

 

今後もSwiftUIのキャッチアップを続け、SwiftUIを盛り上げていけるような情報発信をしていけたらなと思っています!

それでは、最後までご覧いただきありがとうございました。

 

 

 

さいごに

EMoshUでは、Swift、SwiftUIを使用してワクワクしながら開発したい! そんな、仕事にたいして情熱がある方を募っています。

一緒に同じ志を持って試行錯誤しながら成長したいという方は、ぜひ募集要項をご確認の上、ご連絡いただければと思います。

 

 

 

参考文献

 

 

 

募集要項

 

 

 

まずは話を聞いてみたいという方へ