この記事はGoodpatchエンジニアアドベントカレンダー5日目の記事です。
こんにちは!iOSエンジニアのとうようです。2日目の記事も書いているのですが、あまり技術的な内容は触れなかったのと、まだ空いていたので急遽この枠で技術的な記事も書いていこうということにしました。
多分このアドベントカレンダーはほとんどQiitaに書く人がいないので、一個ぐらいはQiitaに書き残しておこうかなとここで書き進めています。
今回書く内容は、自分が実際に参加した案件での実例を通したSwiftUI周りのお話になります。実際どのアプリなのかは伏せさせていただきますが、以下のような前提条件での学びになるためそこを踏まえた上で、**実プロダクトに現段階でSwiftUIをどう取り入れるべきなのか?**といったところの参考にしてもらえればいいなと思います。
- 途中までiOS13対応も考慮されていた
- アプリのリニューアルに際して導入されたため、ベースはUIKitであり、実際の見た目の部分を
UIHostingController
を使ってSwiftUIで実装している - また、画面遷移はStoryboard + Wireframeパターン1で作られており、全画面にStoryboardファイルが存在している
また、自分が担当した役割の性質上、デザイン寄りの話題もだいぶ多いことも踏まえた上でお読みいただければと思います。
Listのセパレーターを消すには
UITableView
のような表示をしたい時、List
だとセパレーターが入ってしまいます。普通であれば別に問題ないですが、デザイン上消したいということもあるでしょう。そのようなときどのような方法があるでしょうか?
まず、通常コンポーネントで各iOSでの解決法を見ていきます。
解決法 | iOS13 | iOS14 | iOS15 |
---|---|---|---|
Listのままの解決法 | なし | なし | .listRowSeparator(.hidden)を使う |
それ以外の方法 | VStackを使う | LazyVStackを使う | LazyVStackを使う |
この表から分かるように、iOS15以降でしか正式な手段は提供されていません。
またそれ以外の方法もLazyVStack
を使えるiOS14以降はいいですが、iOS13だとただのVStack
になってしまうため大量の要素を表示する際のパフォーマンス面が気になります。
これらを全てのバージョンについて解決する方法としては、二つの手法を組み合わせるアプローチを取りました。
一個が、基本的に内部でUIKitがレンダリングに使われているiOS13に対応するためのSwiftUI-Introspect、もう一つがiOS14のためのちょっとしたハックModifierです。
iOS13については完全にUITableView
のパラメータをいじらなければ見た目を変えることができないのですが、もちろん外側から普通にやっていじることはできません。そこで再帰関数を用いて中のUIKitのパーツまでいきいじれるようにするのがSwiftUI-Introspectです。ライブラリを導入して、.introspectTableView { tableView in }
というModifierで中のUITableView
のあれこれをいじることができます。
iOS14は少し厄介です。iOS15のように設定できるものがないのですが、レンダリングはSwiftUI独自のものになっているため、Introspectでいじっても見た目には反映されません。
大人しくLazyVStack
を使えという話ではあるのですが、iOS13対応をしていると単純にそうもいかないので、List
のまま解決する方法がAppleのフォーラムに上がっています。
具体的には以下のようなViewModifier
を用意することになります。
struct HideRowSeparatorModifier: ViewModifier {
static let defaultListRowHeight: CGFloat = 44
var insets: EdgeInsets
var background: Color
init(insets: EdgeInsets, background: Color) {
self.insets = insets
var alpha: CGFloat = 0
if #available(iOS 14, *) {
UIColor(background).getWhite(nil, alpha: &alpha)
assert(alpha == 1, "Setting background to a non-opaque color will result in separators remaining visible.")
}
self.background = background
}
func body(content: Content) -> some View {
content
.padding(insets)
.frame(minWidth: 0, maxWidth: .infinity,
minHeight: Self.defaultListRowHeight, alignment: .leading)
.listRowInsets(EdgeInsets())
.background(background)
}
}
これは何をやっているのかを図解してみたものがこちらです。
SwiftUIのList
のレンダリングの要素としては、中のコンテンツ、そしてListRowというものがあると考えられます(完全に推測です)
さまざまなModifierはこのListRowに対しての設定がされていると見ていいでしょう。セパレーターも同様です。そして全般的に重なり順としてはコンテンツが上に来るようになっています。
そこで.listRowInsets
を0にしてあげるとコンテンツが目一杯に広がり、透過されていない限りセパレーターを隠してくれます。
唯一解決できないのは全体の一番上についているセパレーターです。下に引っ張らないと出てこない部分ではあるのですが、こちらは色々調整してみたものの消すことはできませんでした。
この工夫により無事セパレーターを全バージョンList
のまま消すことが叶ったのですが、いかんせんこのModifierの中で余白などを指定することになるのでレイアウトの調整が煩雑になります。iOS13を切れるならiOS13を切って、LazyVStack
を使う方が幸せになれると思います。
スクロール量を検知する
続いてはスクロール量を検知する際の話です。基本的にSwiftUIでこのような座標などをとる操作をするときにはGeometryReader
というものを使うのですが、これに関してiOS13とiOS14以降で少し挙動が変わるため注意が必要でしたという話です。
いくつかの記事ではスクロール量をとる際に一つのGeometryReader
を使ってスクロール量を直接検知しようとするコードがあるのですが、ここに少し罠があります。
何かというと、以下のようにスクロール量として取れるoffset
の基準がiOSバージョンによって変わってしまうのです。
特にLarge Titleがある画面だと注意が必要になります。そこでこちらの記事のように二個のGeometryReader
を使って差分を取ることでこのバージョン差を無くすという解決法に至りました。
struct TrackableScrollView<Content: View>: View {
private let axes: Axis.Set
private let showIndicators: Bool
private let content: Content
private let onChangeOffset: (CGFloat) -> Void
init(
_ axes: Axis.Set = .vertical,
showIndicators: Bool = true,
onChangeOffset: @escaping (CGFloat) -> Void,
@ViewBuilder _ content: () -> Content
) {
self.axes = axes
self.showIndicators = showIndicators
self.onChangeOffset = onChangeOffset
self.content = content()
}
var body: some View {
GeometryReader { outsideProxy in
ScrollView(axes, showsIndicators: showIndicators) {
content
.background(GeometryReader { insideProxy in
Color.clear.preference(
key: ScrollViewOffsetKey.self,
value: calculateContentOffset(from: outsideProxy, insideProxy: insideProxy)
)
})
.onPreferenceChange(ScrollViewOffsetKey.self) {
onChangeOffset($0)
}
}
}
}
private func calculateContentOffset(from outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
if axes == .vertical {
return outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY
} else {
return outsideProxy.frame(in: .global).minX - insideProxy.frame(in: .global).minX
}
}
}
private struct ScrollViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue: Value = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
ただしこれをList
にも応用したいというときは注意が必要です。これはあくまでスクロールする中のコンテンツに透明な背景色をつけ、その要素の座標をとっているだけです。そのため、List
のようなものの場合一番上にくる要素にこの座標取得用の背景をつけるようにしないと正しい座標を取れなくなってしまいます。コンポーネントとして切り出すのはやや難しい対応になってしまうので、その場合は個別実装することになるでしょう。
NavigationBarのあれこれ
SwiftUIをUIHostingController
で使う際、NavigationBar
の扱いは少しややこしくなります。
UIKitかSwiftUIのどちらかによっていれば起きにくいことではあるのですが、以下の問題がありました。
-
NavigationBar
を隠す設定がUIHostingController
のデフォルト挙動で上書きされてしまう問題 -
ScrollView
/List
が厳密に最背面にいないとLarge Titleなどスクロールによって挙動が変わる機能が正常に働かない問題
一個ずつ見ていきます。
NavigationBarを隠す設定がUIHostingControllerのデフォルト挙動で上書きされてしまう問題
何らかの理由でNavigationBar
を隠したい時、SwiftUI + UIHostingController
だとUIKit側のライフサイクルで設定しても反映されないという現象があります。iOS14まではこのワークアラウンドとして、SwiftUI側で.navigationBarHidden(true)
をするという解決法がよく言われていましたがこれがiOS15から効かなくなりました。
これに関してよくよく探っていくと、どうやらUIHostingController
のライフサイクルにNavigationBarを表示するような動作が入っており、その実行が親のUIViewController
のライフサイクルの後になってしまうためにうまくいってないようでした。
逆に言えば、UIHostingController
のライフサイクルのタイミングで諸々の設定ができれば良さそうです。そのためにこのようなクラスを用意することで解決することができました。
class CustomHostingController<Content>: UIHostingController<AnyView> where Content: View {
private var onViewWillAppear: (() -> Void)?
private var onViewWillDisapper: (() -> Void)?
public init(onViewWillAppear: (() -> Void)?, onViewWillDisapper: (() -> Void)?, rootView: Content) {
self.onViewWillAppear = onViewWillAppear
self.onViewWillDisapper = onViewWillDisapper
super.init(rootView: AnyView(rootView))
}
@available(*, unavailable)
@MainActor @objc dynamic required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
onViewWillAppear?()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
onViewWillDisapper?()
}
}
このCustomHostingController
はviewWillAppear
とviewWillDisappear
で実行してほしい内容を渡すことができるため上書きされずに対処が可能です。
もちろん他のライフサイクルでも何か実行したいことがあれば拡張することもできるでしょう。中でどのようなデフォルト動作が組まれているかは謎ですが、もし何かうまく設定できないということがあればこちらを試してみるのもおすすめです。
ScrollView/Listが厳密に最背面にいないとLarge Titleなどスクロールによって挙動が変わる機能が正常に働かない問題
こちらの問題は実はUIKitにも存在しているものです。スクロールする要素が最背面かつセーフエリアなど画面一番上まで領域が広がっていないと、Large Titleがスクロールに合わせてしまわれなかったり、iOS15だとスクロールしてもNavigationBarの背景色がつかずに透明なままになったりします。
UIKit + SwiftUIではこのためにいくつかチェックする必要のある項目がありました。
- SwiftUIをアタッチするViewの後ろに別のViewがないか
-
ScrollView
/List
が画面上部まで広がっているか?またZStack
などで後ろに他の要素がある状態になっていないか -
ScrollView
/List
にbackground
がついていないか
特に最後のポイントが少し厄介です。この問題でいう重ね順にはbackground
でつけた要素も別個のものとしてカウントされてしまいます。そのためもし背景色をつけたい場合は、UIHostingController().view.backgroundColor
でUIKit側から設定するようにしなければなりません。
実は存在した、iPhone世代間の挙動の差異
他にもさまざまあるのですがあまりにも長くなり過ぎてしまうのでこれで最後のトピックにしようと思います。最後に取り上げるのはiPhoneの世代間の差異です。iOSのバージョン違いやiPhoneのサイズの違い、あるいはセーフエリアの有無とかいう話ではありません。
一番顕著だったところで言うと、iPhone11までとiPhone12以降でいくつかの挙動が変わっていたのでそれを最後に紹介したいと思います。
今回発見しているのは以下の二点です。ただ、これがある以上注意深く検証すれば他にもあるかもしれません。
-
Picker
の標準の大きさが違う -
Text
のトランケーションされる基準が違う
この二つはほとんど同じ原因の問題とも考えられます。実はiPhone12以降、若干標準コンポーネントの大きさが大きくなっている場合があるのです。(少なくともそう考えるしかないようなバグがちらほらありました)
そのため、Picker
に関してはframe
で大きさを想定通りのものに調整できるようにし、Text
は発見するたびにfixedSize(horizontal: false, vertical: true)
をつけていく対応が発生しました。
Dynamic Typeを扱ったりもしていたので完全に推測通りの原因とは言い切れませんが、少し検証時に注意が必要なのかもしれません。
まとめ
以上、SwiftUIを実際に実プロダクトに使ったときに見つかった少し変わった注意点をいくつかご紹介してみました。
個人的な肌感としては、サポートはなるべくiOS15以上にできるアプリで、なおかつSwiftUIをメインに、一部UIViewRepresentable
で対応するというのが実プロダクトでSwiftUIをストレスなく使う条件になってくると思いました。ただまだまだ足りない機能やonAppear
の挙動が不安定などの問題もあるのでうまく検証しながら向き合っていきたいですね。
-
VIPERアーキテクチャのRouterで使う、Wireframeプロトコルをプロトコルエクステンションで記述したものです。extensionの中でStoryboardからの初期化やさまざまな設定を実装することで、各画面からは関数を呼び出すだけで遷移できるようになり、個別の実装を行う必要がなくなります。 ↩