2021年11月にはてなが開発しているマンガビューワのアプリ版である「GigaViewer for Apps」を提供開始しました。
同日に、その最初の導入事例としてコミックガルド+をリリースしています。
GigaViewer for AppsのiOSアプリはSwiftUIをフルに活用して開発をしています。この記事では実際に使っているSwiftUIの便利コンポーネントをソースコード付きで紹介します!
GigaViewer for Appsに関しては、座談会の記事や、Android版の設計について書いたブログもありますので、あわせてお読みください。
この記事では主にカスタムコンポーネントの使い方を紹介します。コンポーネントの実装サンプルはリポジトリに公開しているので、こちらもご確認ください。
- PageViewController - UIPageViewControllerの活用
- TabMenu - ViewPagerのような画面の実装
- CustomFont - 独自のタイポグラフィーを定義
- ScreenState - 典型的な画面の状態を管理
- SwiftUICollectionViewCell - Collection ViewのセルとしてSwiftUIを使用
- まとめ
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
は直前の値を覚えておくプロパティラッパーで、アニメーションの方向を決定するために使います。
Appleが公開しているSwiftUIのチュートリアルを終えている方は、同じようなラッパーを作った記憶があるかもしれません。また、TabView
のPageTabViewStyle()
でも同じことが実現できると思っている方もいらっしゃると思います。
今回紹介する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で、実際に使っているコンポーネントを切り出して紹介してきました。参考になったら嬉しいです。
今回紹介したコンポーネントはサンプルアプリとして公開しています。こちらもあわせてご確認ください。
はてなでは、一緒にGigaViewer for Appsを作り上げていく仲間を募集しています!カジュアル面談もできますので、お気軽にご連絡ください!
id:kouki_dan
斉藤 洸紀(さいとう・こうき)。2019年1月入社。マンガチームでスマートフォンアプリケーションエンジニア/テックリードを務める。
Twitter: @kouki_dan
GitHub: kouki-dan
blog: Lento con forza