AkkeyLab

Perform full-scale application development with SwiftUI

SwiftUI は WWDC2019 で発表されたフレームワークで、 UI 構築を革新的かつ極めてシンプルに構築することができます。今回は、その SwiftUI で本格的なアプリを開発する上で必要となりえるテクニックをご紹介します。 なお、リリースから約1年ほどしか経過しておらず、今後も大幅なバージョンアップが考えられますので、最新の言語仕様を確認した上で御覧ください。

はじめに

Apple 公式で準備されているチュートリアルの内容はある程度把握していることが最低条件となります。この記事では以下のような内容を取り上げます。興味を持っていただけたのであれば、記事を読み進めることをお勧めします。

  • 縦長、横長、サイズがバラバラな画像を比率固定で正方形表示させる
  • URL から画像をロードして表示させる
  • UICollectionView のような NxN 配列のリスト配列を行う
  • 複数種類のアラート表示を行う

アスペクト比率固定で正方形表示

Image from Gyazo
サーバから返ってくる画像のサイズは大きく異なっており、縦長・横長が混在していると仮定します。これを統一したサイズで表示することを考えます。このとき、縦長の画像が縦方向に潰れたり、横長の画像が横方向に潰れたりせず、無駄な余白も作ってはいけないと仮定します。
まず、 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」といった内容の画像が想定されます。 imagePublished として定義されており、この変数の値の変化を外部に伝えることができます。ただし、そのためにはローダクラスが 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 を利用することをおすすめします。
処理が多少複雑なため、まずは使い方からご覧いただきます。非常にシンプルに見えます。もちろん、この CollectionViewUICollectionView のラッパーではありません。


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 で指定しています。一番外側の隙間も同様にここで作りたい場合は VStackSpacer() を上下にサイズ指定して配置すると良いでしょう。
横一列を生成する 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 でセルの横方向の間隔サイズを指定しています。一番外側の隙間も同様にここで作りたい場合は HStackSpacer() を左右にサイズ指定して配置すると良いでしょう。
画像を表示する 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,
             ...
        }
    }
}

優先度によるレイアウト

Image from Gyazo
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)
    }
}

おわりに

実践レベルであると判断した内容を抽出してまとめてみました。アイデア、ご指摘等ございましたら、このホームページの連絡フォームより気軽に送っていただけると嬉しいです。
最後までお読みいただき、ありがとうございます。

Author

Next post

1st anniversary! Introducing the evolution of screen design!

AkkeyTV は 2019 年春にリリースされたユニークな動画視聴プラットフォームです。デザイナー泣かせな機能を特徴とし、新たな動画視聴体験を届けるために現在も開発を行っております。
今回は、そんな AkkeyTV 1周年を記念に UI の大型アップデートを行いましたので、デザイン制作の裏側をご紹介いたします。

Read More →