GigaViewer for Appsで使っている便利SwiftUIコンポーネント5連発!

2021年11月にはてなが開発しているマンガビューワのアプリ版である「GigaViewer for Apps」を提供開始しました。

hatenacorp.jp

同日に、その最初の導入事例としてコミックガルド+をリリースしています。

GigaViewer for AppsのiOSアプリはSwiftUIをフルに活用して開発をしています。この記事では実際に使っているSwiftUIの便利コンポーネントをソースコード付きで紹介します!

GigaViewer for Appsに関しては、座談会の記事や、Android版の設計について書いたブログもありますので、あわせてお読みください。

この記事では主にカスタムコンポーネントの使い方を紹介します。コンポーネントの実装サンプルはリポジトリに公開しているので、こちらもご確認ください。

github.com

PageViewController - UIPageViewControllerの活用

まず紹介するのはPageViewControllerです。

UIPageViewControllerをSwiftUI経由で使えるようにしたもので、以下のように使います。

struct PageScreen: View {
    @State @WithPrevious var page = 0

    var body: some View {
        PageViewController(
            pages: [
                Text("Page1"),
                Text("Page2"),
                Text("Page3"),
            ],
            currentPage: $page
        )
    }
}

@WithPrevious は直前の値を覚えておくプロパティラッパーで、アニメーションの方向を決定するために使います。

github.com

Appleが公開しているSwiftUIのチュートリアルを終えている方は、同じようなラッパーを作った記憶があるかもしれません。また、TabViewPageTabViewStyle()でも同じことが実現できると思っている方もいらっしゃると思います。

今回紹介するPageViewControllerは、チュートリアルのコードやTabViewでは対応できないユースケースを解決します。具体的には

  • pagesの要素数が動的に変化するケースにも対応可能
  • currentPageの変化を外から与える場合にも、正しい方向でのアニメーションが可能
  • 一番最初のページで左側にスワイプすると、一番最後のページに移動可能(逆も可能)

といった改良を加えています。 具体的なソースコードはこちらから確認できます。

GigaUI-Sample/PageViewController.swift at main · hatena/GigaUI-Sample · GitHub

TabMenu - ViewPagerのような画面の実装

次に紹介するTabMenuは、先ほどのPageViewControllerと合わせて使うことを想定しています。よく上にタブがあるViewPagerのような画面を作りたくなることがありませんか? 2つのカスタムモディファイア(tabMenuAnchorPreference(isSelected:)tabMenuIndicatorOverlay())と先ほど紹介したPageViewControllerを組み合わせて使うことで、このGIFアニメにあるような画面を作れます。

struct TabMenuScreen: View {
    let tabs = ["Tab1", "Tab2", "Tab3"]
    @State @WithPrevious var selection = 0

    var body: some View {
        VStack {
            HStack(spacing: 0) {
                ForEach(Array(tabs.enumerated()), id: \.offset) { index, tab in
                    Button {
                        withAnimation {
                            selection = index
                        }
                    } label: {
                        Text(tab)
                            .foregroundColor(index == selection ? Color.blue : Color.primary)
                            .padding()
                            .frame(maxWidth: .infinity)
                    }
                    .tabMenuAnchorPreference(isSelected: index == selection)
                }
            }
            .tabMenuIndicatorOverlay()
            PageViewController(
                pages: tabs.map { Text($0) },
                currentPage: $selection.animation()
            )
        }
    }
}

タブの要素に tabMenuAnchorPreference(isSelected:) モディファイアを、タブ全体に tabMenuIndicatorOverlay() モディファイアを適用します。

このモディファイアはPreferenceKeyを通じて選択中タブのboundsを取得し、タブの下部に選択中であることを表すインジケータが移動します。

ソースコード全体はこちらからご確認ください。

GigaUI-Sample/TabMenu.swift at main · hatena/GigaUI-Sample · GitHub

CustomFont - 独自のタイポグラフィーを定義

iOS Human Interface Guidelineタイポグラフィーには Body や Large Titleなどのフォントスタイルが定義されています。しかし、この定義とは違うタイポグラフィーを独自に定義して使いたいことがあります。

GigaViewer for Appsでは、独自のenumでカスタムタイポグラフィーを定義しています。

enum CustomTypography {
    /// title1 size: 21, weight: .bold
    case title1
    /// title2 size: 19, weight: .bold
    case title2
    /// subtitle1 size: 17, weight: .regular
    case subtitle1
    /// subtitle2 size: 13, weight: .bold
    case subtitle2
    /// body1 size: 15, weight: .regular
    case body1
    /// body2 size: 13, weight: .regular
    case body2
    /// button size: 15, weight: .regular
    case button
    /// caption size: 13, weight: .regular
    case caption

    var font: CustomFont {
        switch self {
        case .title1:
            return CustomFont(size: 21, style: .title1, weight: .bold)
        case .title2:
            return CustomFont(size: 19, style: .title2, weight: .bold)
        case .subtitle1:
            return CustomFont(size: 17, style: .title3, weight: .regular)
        case .subtitle2:
            return CustomFont(size: 13, style: .headline, weight: .bold)
        case .body1:
            return CustomFont(size: 15, style: .subheadline, weight: .regular)
        case .body2:
            return CustomFont(size: 13, style: .body, weight: .regular)
        case .button:
            return CustomFont(size: 15, style: .body, weight: .regular)
        case .caption:
            return CustomFont(size: 13, style: .caption1, weight: .regular)
        }
    }
}

CustomFontはViewModifierで、Dynamic Typeを適用したサイズのシステムフォントを適用します。通常フォントをサイズ指定で使った場合はDynamic Typeが有効になりませんが、UIFontMetricsを通してサイズを取得することで、ユーザーが期待するフォントサイズでテキストの描画が可能になっています。

Extra Small Large Accessibility Extra Large

このフォントを適用するためのモディファイアを定義していて、以下のようにフォントを適用します。

struct CustomFontScreen: View {
    var body: some View {
        VStack {
            Text("Title 1")
                .customFont(.title1)
            Text("Title 2")
                .customFont(.title2)
            Text("Subtitle 1")
                .customFont(.subtitle1)
            Text("Subtitle 2")
                .customFont(.subtitle2)
            Text("Body 1")
                .customFont(.body1)
            Text("Body 2")
                .customFont(.body2)
            Text("Caption")
                .customFont(.caption)
            Button("Button") {}
                .customFont(.button)
        }
    }
}

ソースコード全体はこちらです。

GigaUI-Sample/CustomFont.swift at main · hatena/GigaUI-Sample · GitHub

実際には、dynamicTypeSize(_:)のように、Dynamic Typeが取りうるサイズを制限するAPIも実装して使っています。

ScreenState - 典型的な画面の状態を管理

典型的な画面の実装では、画面表示時にAPIリクエストを行います。レスポンスを受け取るまではローディング画面を表示していて、レスポンスを受け取り次第データを表示します。

このケースではローディングの状態と、ロード済みの状態が存在します。他にも、APIリクエストが失敗した時のためのエラー状態と、要素が0件の時のための空状態があります。

それらの状態を管理するために、ScreenStateというenumを定義しています。

enum ScreenState<Value, Error: Swift.Error> {
    case loading
    case failed(Error)
    case empty
    case loaded(Value)
}

SwiftUIのViewでは、ScreenStateProjectorというViewを定義していて、このViewにScreenState、再読み込みのためのrefreshAction、データ読み込みが成功した時に表示されるonLoadedを渡します。必要に応じてエラー表示と空表示のビューも渡せますが、渡さなかった場合はデフォルトの表示が使われます。

struct ScreenStateScreen: View {
    @State var screenState: ScreenState<String, Error> = .loading

    var body: some View {
        NavigationView {
            ScreenStateProjector(
                screenState,
                refreshAction: refresh,
                onLoaded: { data in
                    // ここにAPIレスポンスのデータが入ってくるので画面を構築する
                    Text(data)
                }
                // 必要な場合はエラーと空表示のビューもカスタマイズ可能
            )
            .navigationTitle("Screen State Sample")
            .navigationBarTitleDisplayMode(.inline)
        }
        .navigationViewStyle(.stack)
        .onAppear {
            // APIの呼び出しを行う
        }
        .tabItem {
            Label("State", systemImage: "cloud")
        }
    }
}
ローディング エラー表示 読み込み後画面表示

実際はもう少しリッチなエラー表示がデフォルトで表示されるようにして使っていますが、APIリクエストを伴う画面ではScreenStateで状態を管理し、ScreenStateProjectorで画面を構築しています。

全体のソースコードはこちらです。

GigaUI-Sample/ScreenState.swift at main · hatena/GigaUI-Sample · GitHub

SwiftUICollectionViewCell - Collection ViewのセルとしてSwiftUIを使用

最後に紹介するのは、UICollectionViewのセルにSwiftUIを使うためのコンポーネントです。SwiftUIをメインに使ってはいますが、Collection Viewの表現力を使いたいケースもよくあります。その場合にはセルの中身をSwiftUIで実装することで、Collection ViewとSwiftUIのいいとこ取りをしています。

使い方は、セルとして使いたいSwiftUIのビューにCollectionViewCellWrappableを適用して、Collection Viewから使うことを宣言します。

struct Cell: View, CollectionViewCellWrappable {
    let systemName: String

    var body: some View {
        VStack {
            Image(systemName: systemName)
                .resizable()
                .aspectRatio(1, contentMode: .fit)
            Text(systemName)
        }
    }
}

Collection Viewでは、viewDidLoad()でregisterし、collectionView(_:cellForItemAt:)でdequeueして、SwiftUIのビューをrenderします。

class CollectionViewController: UICollectionViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.register(Cell.self)
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(Cell.self, for: indexPath)
        cell.render(rootView: Cell(systemName: symbols[indexPath.row]), parentViewController: self)
        return cell
    }
}

実装はこちらです!

GigaUI-Sample/SwiftUICollectionViewCell.swift at main · hatena/GigaUI-Sample · GitHub

実際のコードベースには、ヘッダーやフッターの分も実装して使っています。

まとめ

SwiftUIをメインに使っているGigaViewer for Appsで、実際に使っているコンポーネントを切り出して紹介してきました。参考になったら嬉しいです。

今回紹介したコンポーネントはサンプルアプリとして公開しています。こちらもあわせてご確認ください。

github.com

はてなでは、一緒にGigaViewer for Appsを作り上げていく仲間を募集しています!カジュアル面談もできますので、お気軽にご連絡ください!

はてなでは、技術に対する向上心を持つ仲間を募集しています