UIViewRepresentable -> UIScrollView -> UIHostingController でフリーズする
タイトルのとおりです。
皆さんは SwiftUI.ScrollView ではなく UIKit.UIScrollView を使いたいと思ったことはありませんか? iOS 14 でも RefreshControl が使えるし、UIScrollViewDelegate を使えば contentOffset も自在にコントロールできる。古き良き UIScrollView……慣れ親しんだ UIScrollView……そんな UIScrollView を SwiftUI でも使いたいと思ったことがあるはずです。
問題のコード
以下のコードはフリーズします。
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