📜

UIViewRepresentable -> UIScrollView -> UIHostingController でフリーズする

2021/12/10に公開

タイトルのとおりです。

皆さんは SwiftUI.ScrollView ではなく UIKit.UIScrollView を使いたいと思ったことはありませんか? iOS 14 でも RefreshControl が使えるし、UIScrollViewDelegate を使えば contentOffset も自在にコントロールできる。古き良き UIScrollView……慣れ親しんだ UIScrollView……そんな UIScrollView を SwiftUI でも使いたいと思ったことがあるはずです。

問題のコード

以下のコードはフリーズします。

CustomScrollView.swift
import SwiftUI
import Combine

struct CustomScrollView<Content: View>: UIViewRepresentable {

    let content: () -> Content
    let axis: Axis

    init(axis: Axis = .vertical, @ViewBuilder content: @escaping () -> Content) {
        self.content = content
        self.axis = axis
    }

    func makeUIView(context: Context) -> HostingScrollView<Content> {
        let scrollView = HostingScrollView(rootView: content())
        layout(scrollView: scrollView)
        return scrollView
    }

    func updateUIView(_ uiView: HostingScrollView<Content>, context: Context) {

        uiView.rootViewController.rootView = content()
        uiView.rootViewController.view.invalidateIntrinsicContentSize()
    }

    private func layout(scrollView: HostingScrollView<Content>) {

        guard let view = scrollView.rootViewController.view else { return }

        switch axis {
        case .horizontal:
            scrollView.addConstraints([
                view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
                view.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
                view.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
                view.heightAnchor.constraint(equalTo: scrollView.heightAnchor),
            ])
        case .vertical:
            scrollView.addConstraints([
                view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
                view.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
                view.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
                view.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
            ])
        }
    }
}

class HostingScrollView<Content: View>: UIScrollView {

    let rootViewController: UIHostingController<Content>

    init(rootView: Content) {
        rootViewController = UIHostingController<Content>(rootView: rootView)
        super.init(frame: rootViewController.view.frame)

        if let view = rootViewController.view {
            view.translatesAutoresizingMaskIntoConstraints = false
            addSubview(view)
        }
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

struct ContentView: View {

    @State var height: CGFloat = 720

    var body: some View {
        CustomScrollView {
            Rectangle()
                .onTapGesture {
                    height += 6
                    print("height:", height)
                }
                .frame(height: height)
        }
    }
}

新規プロジェクトで実行するだけでフリーズしてくれる良いコードです。ただし、iOS 14 でのみ発生します。え? つまり iOS のバグってこと?

補足

もちろんこのコードだけだと UIScrollView を使うメリットなどありませんので、実際には Binding<CGFloat> を渡すと contentOffset と同期してくれたり、 Binding<Bool> を渡すと UIRefreshControl.isRefreshing と同期してくれたりするアレコレを追加で投入していますが、今回のバグとは無関係のようです。

Rectangle を適当な回数タップするとフリーズします。UIScrollView の Frame Layout の高さをコンテンツの高さが上回った瞬間、フリーズするようです。ちなみに、ある程度以上の高さがあるとフリーズしないようです。

UIHostingController をログを仕込んだバージョンに置き換えると、どうやら UIHostingController の view の高さが不安定になるらしいということがわかりました。それがメインループを脱することなく永久に続くので、画面のレンダリングが確定せずに完全にユーザーの操作を受け付けなくなります。

解決方法

iOS 14 のときだけ、フリーズが起こらない高さ・幅を保証することで回避しました。

private struct FixScrollViewEarthquakeBugModifier: ViewModifier {

    let axis: Axis

    var minWidth: CGFloat? {
        if #available(iOS 15.0, *) {
            return 0
        } else {
            return UIScreen.main.bounds.width
        }
    }

    var minHeight: CGFloat? {
        if #available(iOS 15.0, *) {
            return 0
        } else {
            return UIScreen.main.bounds.height
        }
    }

    func body(content: Content) -> some View {
        switch axis {
        case .horizontal:
            content
                .frame(minWidth: minWidth, alignment: .leading)
        case .vertical:
            content
                .frame(minHeight: minHeight, alignment: .top)
        }
    }
}

iOS 15 に関する補足

iOS 15 だと、このフリーズ現象が起こらない代わりにコンテンツの高さが 0 になるという現象が起こります。こちらは、 UIHostingController の invalidateIntrinsicContentSize() を呼んであげることで回避できます。

(CustomScrollView では updateUIView 時点で呼んでいます)

最後に

SwiftUI.ScrollView で contentOffset への Binding や iOS 15 未満での pull to refresh が使えれば別にこんなことしなくていいんですけどね。

Discussion