SwiftUI
は WWDC2019 で発表されたフレームワークで、 UI 構築を革新的かつ極めてシンプルに構築することができます。今回は、その SwiftUI
で本格的なアプリを開発する上で必要となりえるテクニックをご紹介します。 なお、リリースから約1年ほどしか経過しておらず、今後も大幅なバージョンアップが考えられますので、最新の言語仕様を確認した上で御覧ください。
はじめに
Apple 公式で準備されているチュートリアルの内容はある程度把握していることが最低条件となります。この記事では以下のような内容を取り上げます。興味を持っていただけたのであれば、記事を読み進めることをお勧めします。
- 縦長、横長、サイズがバラバラな画像を比率固定で正方形表示させる
- URL から画像をロードして表示させる
- UICollectionView のような NxN 配列のリスト配列を行う
- 複数種類のアラート表示を行う
アスペクト比率固定で正方形表示
サーバから返ってくる画像のサイズは大きく異なっており、縦長・横長が混在していると仮定します。これを統一したサイズで表示することを考えます。このとき、縦長の画像が縦方向に潰れたり、横長の画像が横方向に潰れたりせず、無駄な余白も作ってはいけないと仮定します。
まず、 resizable()
を用いて画像のサイズ変更が可能な形式に変換します。次に、 scaledToFill()
で画面いっぱいに画像を広げます。この時、画像は比率を保持しつつリサイズし、親 View から一部の辺がはみ出すことがあります。
最後に frame()
を用いて任意のサイズに変更します。ただし、親 View の条件によってはそれでも画像がはみ出してしまいます。その場合は、 clipShape()
を用いて切り抜きを行ってください。ここでは角丸も指定してみました。
image
.resizable()
.scaledToFill()
.frame(width: geometry.size.width, height: geometry.size.height)
.clipShape(RoundedRectangle(cornerRadius: Const.cornerRadius))
.shadow(color: Assets.primaryShadow.color.toColor, radius: Const.shadowRadius)
画像の非同期ロード
今回は Nuke というライブラリを用いてキャッシュを最大限利用して画像の読み込みを行う事例を示します。
まず、画像を非同期で取得し、その取得完了を通知するためのローダを作成します。 load()
では画像のロード処理を行っており、取得に成功した場合はその画像を、失敗した場合はデフォルト画像を image
変数にセットしています。このデフォルト画像は「No Image」といった内容の画像が想定されます。
image
は Published
として定義されており、この変数の値の変化を外部に伝えることができます。ただし、そのためにはローダクラスが ObservableObject
に準拠している必要があります。
非同期処理を行う上で取得できる AnyCancellable
はインスタンスが破棄されるときに非同期処理をキャンセルするために使用します。
import Combine
import SwiftUI
import Nuke
final class ImageLoader: ObservableObject {
@Published var image: SwiftUI.Image = Assets.default.image.toImage
private var cancellable: AnyCancellable?
deinit {
cancellable?.cancel()
}
internal func load(url: URL?) {
guard let url = url else { return }
cancellable = ImagePipeline.shared.imagePublisher(with: url)
.sink(
receiveCompletion: { [weak self] res in
switch res {
case .failure:
self?.image = Assets.default.image.toImage
case .finished:
break
}
},
receiveValue: { [weak self] res in
self?.image = res.image.toImage
}
)
}
}
ローダを使った画像表示が行える汎用的な View を作っておきます。これによってローダの管理がこの一箇所で済みます。
image
の値変化を監視すためにローダインスタンスは ObservedObject
で保持しておきます。
画像の表示はクロージャを通して行っています。このようにすることで、画像に対して外部から処理を行うことができます。例えば、リサイズや角丸にするなど。
また、View が生成されてから画像のロードを行うことでパフォーマンスを安定化する必要があります。そこで、 onAppear
をトリガーにしてロード処理を開始するようにしています。
import SwiftUI
struct CustomImage<T>: View where T: View {
@ObservedObject internal var imageLoader = ImageLoader()
private let url: URL?
private let content: (_ image: Image) -> T
init(url: URL?, content: @escaping (_ image: Image) -> T) {
self.url = url
self.content = content
}
var body: some View {
content(imageLoader.image)
.onAppear {
self.imageLoader.load(url: self.url)
}
}
}
以下のように利用することができます。
CustomImage(url: self.url) {
$0
.resizable()
.scaledToFill()
}
NxN のリスト表示
パフォーマンスの観点でまだ改善の余地がある実装であることをご理解ください。そのため、セルの再利用をふんだんに利用して高パフォーマンスなリスト表示が求められる場合は UIKit を利用することをおすすめします。
処理が多少複雑なため、まずは使い方からご覧いただきます。非常にシンプルに見えます。もちろん、この CollectionView
は UICollectionView
のラッパーではありません。
struct ContentView: View {
@ObservedObject private var viewModel = SearchViewModel()
var body: some View {
GeometryReader { geometry in
CollectionView(data: self.$viewModel.photoList, geometry: geometry)
}
}
}
これが CollectionView
の内部実装です。 SwiftUI.ScrollView()
の中に複数の View を表示する手法であることがわかると思います。
struct CollectionView: View {
@Binding internal var data: [Photo]
internal let geometry: GeometryProxy
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
Rows(data: $data, geometry: geometry)
.frame(width: geometry.size.width)
}
}
}
次に Rows
の実装です。ここからは細かい計算処理を省略して表示しております。ご了承ください。
まず、セルの縦方向の間隔サイズを VStack
の spacing で指定しています。一番外側の隙間も同様にここで作りたい場合は VStack
に Spacer()
を上下にサイズ指定して配置すると良いでしょう。
横一列を生成する Columns()
を縦方向に ForEach
で生成しています。一番最後の1行に関しては右側に余白を作る必要が生じるため、最後であることを示すパラメータを追加で指定しています。
private struct Rows: View {
@Binding internal var data: [Photo]
internal let geometry: GeometryProxy
var body: some View {
VStack(alignment: .center, spacing: Env.vSpacing) {
ForEach(0..<rowCount) { row in
Columns(data: self.$data, index: row, geometry: self.geometry)
}
if self.isLastRow {
Columns(data: self.$data, index: rowCount, isLastRow: true, geometry: self.geometry)
}
}
}
}
最後に Columns
の実装です。
ここも Rows
のときと同じ思考法で、 HStack
の spacing でセルの横方向の間隔サイズを指定しています。一番外側の隙間も同様にここで作りたい場合は HStack
に Spacer()
を左右にサイズ指定して配置すると良いでしょう。
画像を表示する PhotoViewCell()
を横方向に ForEach
で生成しています。最後の行である場合は、セルがない個数分 Spacer()
を生成して配置しています。
private struct Columns: View {
@Binding internal var data: [Photo]
internal let index: Int
internal var isLastRow: Bool = false
internal let geometry: GeometryProxy
var body: some View {
HStack(alignment: .center, spacing: Env.hSpacing) {
ForEach(0..<column) { column in
PhotoViewCell(url: URL(string: self.loadData(column: column)))
.frame(width: self.cellSize, height: self.cellSize)
}
if self.isLastRow {
ForEach(0..<emptyColumn) { _ in
Spacer()
.frame(width: self.cellSize, height: self.cellSize)
}
}
}
}
}
この実装の問題点は ForEach
でオブジェクトが一度にすべて生成されてしまうことにあります。つまり、1000個のコンテンツをロードした場合には一度に1000個のオブジェクトが生成され、メモリに格納されます。
また、 ForEach
で順に生成する関係上、すべてのオブジェクトが一度画面上に生成され、レンダリングが開始してしまいます。したがって、画像のローディング処理も全セルに対して一度に開始してしまうのです。
この問題を解決するには、セルの再利用を用いて必要最低限のセルのみを生成し、表示するという仕組みに修正しなければなりません。
複数種類のアラート表示
以下のように alert()
を一箇所に連結して複数定義してしまうと、どちらか一方のみしか動作しないなど、正常に動作しません。ですから、ボタンなどの View ごとに alert()
を指定することが望まれます。
しかし、 API を叩くことによって生じる複数種類のエラーに対するアラート表示などの場合、先程のような回避策では実装上の限界があります。そこで、今回はアラートの複数種類表示に対応する方法を提案してみます。
@State private var tapAction1: Bool = false
@State private var tapAction2: Bool = false
var body: some View {
SampleView()
.onTapGesture {
self.tapAction1 = true
}
.alert(isPresented: $tapAction1) {
Alert(
title: Text("title"),
message: Text("message"),
dismissButton: .default(Text("primaryButton"))
)
}
.alert(isPresented: $tapAction2) {
Alert(
title: Text("title"),
message: Text("message"),
dismissButton: .default(Text("primaryButton"))
)
}
}
まずは、使い方を以下に示します。 alert()
の定義は一箇所のみでとてもシンプルであることがわかると思います。
struct ContentView: View {
@ObservedObject private var viewModel = SearchViewModel()
var body: some View {
GeometryReader { geometry in
...
}
.alert(isPresented: self.$viewModel.alert.state) {
self.viewModel.alert.alert
}
}
次に、 ViewModel の中を見ていきましょう。アラートを表示するかどうかを外部に伝えるために Published
で alert オブジェクトを保持し、 ViewModel は ObservableObject
に準拠させます。
ここでは非同期処理の詳細は省略しますが、各種エラーが流れてきたときに、 AlertState()
オブジェクトを再生成して alert 変数にセットしています。
final class SearchViewModel: ObservableObject {
@Published var alert = AlertState()
init() {
...
authStore.computed.authError
.subscribe(onNext: { [weak self] type in
switch type {
case .developerError:
self?.alert = AlertState(state: true, type: .developerError)
case .maintenance:
self?.alert = AlertState(state: true, type: .maintenance)
case .requiredRetry:
self?.alert = AlertState(state: true, type: .requiredRetry)
case .networkError:
self?.alert = AlertState(state: true, type: .networkError)
case .signInFailed:
self?.alert = AlertState(state: true, type: .signInFailed)
}
})
.disposed(by: bag)
}
}
最後に、 AlertState
クラスを見ていきます。タイプに関しては一部省略しています。ご了承ください。
まず、初期化時の引数ではアラート表示の有無、アラートタイプ、アラートのボタンを押したときのアクションの3種類を挿入することができます。1つ目の引数である「アラート表示の有無」を alert()
側で監視することでアラート表示のトリガーとして使います。
final class AlertState {
enum AlertType {
case confirmBeforeSignIn
...
case none
}
internal var state: Bool
private var type: AlertType
private var action: () -> Void
init(state: Bool = false, type: AlertType = .none, action: @escaping () -> Void = {}) {
self.state = state
self.type = type
self.action = action
}
}
アラート本体は、各種タイプで文言や、アラートの種類を分岐させて生成するようにします。
以上で、複数種類のアラートを一元管理し、シンプルに実装することが可能になります。
extension AlertState {
internal var alert: Alert {
switch type {
case .confirmBeforeSignIn:
title = L10n.Alert.SignIn.title
message = L10n.Alert.SignIn.message
primaryButton = L10n.Common.signIn
secondaryButton = L10n.Common.later
case .developerError:
...
}
switch type {
case .confirmBeforeSignIn:
return Alert(
title: Text(title),
message: Text(message),
primaryButton: .default(Text(primaryButton),
action: {
self.action()
}
),
secondaryButton: .default(Text(secondaryButton))
)
case .developerError,
...
}
}
}
優先度によるレイアウト
layoutPriority()
を用いたレイアウトの一例をご紹介します。
ボタンを表示する View と Spacer()
を VStack
で表示します。しかし、そのままだとボタンとスペーサーが均等に配置されてしまいます。そこで、スペーサーの Priority をボタンより高くすることで、スペーサーが最大限スケールしてくれます。
struct ContentView: View {
var body: some View {
GeometryReader { geometry in
VStack(spacing: .zero) {
self.loadTopSpace(width: geometry.size.width)
Spacer()
.layoutPriority(1)
}
}
}
}
ボタンのカスタマイズ
ボタンの背景色を変更したりサイズ、形を変更する場合は Button()
に対してではなく、 Button()
の引数である label に指定する View に対して行う必要があります。なぜなら、この label に指定した View の領域がタップ領域になるからです。ですから、 Button()
に対して装飾を施してビジュアルを同じにしてもタップ領域が正しくないという事象が発生しうるのです。
struct TopSpaceView: View {
var body: some View {
Button(
action: {
self.action()
},
label: {
Text(text)
.font(.body)
.foregroundColor(Color.white)
.contentShape(Rectangle())
.frame(width: (width / 3) * 2, height: Const.loginButtonHeight)
.background(color)
}
)
.cornerRadius(Const.loginButtonHeight / 2)
.shadow(color: Assets.background.color.toColor, radius: Const.shadowRadius)
}
}
おわりに
実践レベルであると判断した内容を抽出してまとめてみました。アイデア、ご指摘等ございましたら、このホームページの連絡フォームより気軽に送っていただけると嬉しいです。
最後までお読みいただき、ありがとうございます。