NIKKEI TECHNOLOGY AND CAREER

日経電子版をSwiftUIで作ってみた

この記事はNikkei Advent Calendar 2020の1日目の記事です。

アプリチームの高木です。iOSエンジニアなのでiOSの話をします。 本記事では、SwiftUIを使って日経電子版iOSアプリのトップのUIを再現しました。簡易的に実装を紹介します。

自己紹介

普段は日経電子版紙面ビューアーNikkei WaveのiOSアプリ及びBFF(Backends For Frontends)を開発しています。

直近だとiOSDC 2020でiOSで安定した自動ダウンロードを実現する話をしていました。

背景

SwiftUIが発表されてから早一年半経ちました。 UIKitからSwiftUIへいつから移行できる(すべき)か、SwiftUIのみでどの程度のアプリを構成できるのか、気になっている方は多いと思います。 日経電子版のアプリをSwiftUIで再現検証してみたところ、iOS 14から追加されたコンポーネントを用いて、既存のUIが容易に再現できるようになっていました。 本記事では、UIの実装と実装中に発見したバグへの対応を紹介します。

※ 本記事で掲載するソースコードはこの記事を掲載するために一から用意したものです。公開中のアプリのものではありません。

実行環境

  • Xcode 12.0.1 (Swift 5.3)

完成図

Overall

トップ画面の主要な要素をSwiftUIのみで再現できました。 今回はその中でも現状最新のSDKであるSwiftUI 2.0(と呼称します)から使用可能になった3つのコンポーネントに絞って紹介しつつ、日経電子版アプリのUIをどのように再現したのか解説します。 SwiftUI 2.0は、iOS 14.0+ macOS 11.0+ watchOS 7.0+といった2020年リリースのOS以降で利用可能となっています。

UIPageViewController のような横スクロール形式の記事一覧レイアウトを再現し、ナビゲーションバー上にボタンを設置して、記事一覧の内容を切り替えるメニューを表示する箇所の実装を紹介します。

紹介するコンポーネントは以下の通りです。

PageTabViewStyle

ページ単位で画面をスワイプして切り替えできるビューです。UIKitではUIPageViewControllerを用いて実装していたケースが多いのではないかと思います。

import SwiftUI

@main
struct NKApp: App {
    var body: some Scene {
        WindowGroup {
            TabView {
                Text("コンテンツ1")
                    .tabItem {
                        Label("日経電子版", systemImage: "globe")
                    }
                Text("コンテンツ2")
                    .tabItem {
                        Label("朝刊・夕刊", systemImage: "newspaper")
                    }
            }
        }
    }
}

SwiftUIではTabViewを用いて画面の下部のタブバーを設定することができます。

TabView
import SwiftUI

@main
struct NKApp: App {
    var body: some Scene {
        WindowGroup {
            TabView {
                ZStack {
                    Color.red
                    Text("コンテンツ1")
                }

                ZStack {
                    Color.blue
                    Text("コンテンツ2")
                }
            }
            .tabViewStyle(PageTabViewStyle())
        }
    }
}

SwiftUI 2.0からはTabViewのStyleにDefaultTabViewStyleに加えてPageTabViewStyleを設定できるようになりました。 このStyleを適用するとTabViewの各ContentViewをページと扱いUIPageViewControllerのようなUIを構成してくれます。

PageTabView

Workaround

TabViewのContentViewを静的な固定値ではなく、APIから動的に取得する場合、再レンダリングが正常に働かない挙動を確認しています。 TabViewにidメソッドを適用して、コンテンツに変化が起きたら強制的に再レンダリングを行わせることで対応できます。

TabView {
    ForEach(viewModel.tabContents) { content
      Text(content.name)
    }
}
.tabViewStyle(PageTabViewStyle())
.id(viewModel.tabContents.hashValue) // tabContentsのhashの変化で強制的に再レンダリングさせる

ScrollViewReader

SwiftUI 1.0では任意の箇所へとスクロールさせる標準的な方法が存在しませんでした。 2.0からはScrollView内でScrollViewReaderを用いることで任意の位置へのScrollが可能になりました。

import SwiftUI

@main
struct NKApp: App {
    var body: some Scene {
        WindowGroup {
            ScrollView {
                ScrollViewReader { value in
                    ForEach(0..<50) { num in
                        VStack {
                            Text("List \(num)")
                            Divider()
                        }
                        .id(num)
                    }
                    Button("Top") {
                        withAnimation {
                            value.scrollTo(0, anchor: .top)
                        }
                    }
                }
            }
        }
    }
}
ScrollViewReader

何故ここで取り上げたかというと、スクロール位置の調整ができるようになったことを利用して、先程のPageTabViewStyleのページングに用いるメニューを独自のメニューへカスタマイズすることが容易になるためです。

PageTabViewNav

どのページが選択されているか円のハイライトでナビゲーションを表示していた箇所です。

import SwiftUI

@main
struct NKApp: App {

    @State private var selection = 0

    private let colors: [Color] = [.red, .blue, .green, .orange, .pink, .purple]

    var body: some Scene {
        WindowGroup {
            TabView(selection: $selection) {
                ForEach(0..<self.colors.count) { num in
                    ZStack {
                        colors[num]
                        Text("コンテンツ \(num)")
                    }
                    .tag(num)
                }
            }
            .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))

            ScrollView(.horizontal, showsIndicators: false) {
                ScrollViewReader { proxy in
                    HStack {
                        ForEach(0..<self.colors.count) { num in
                            Text("List \(num)")
                                .font(.headline)
                                .padding(.init(top: 4, leading: 8, bottom: 4, trailing: 8))
                                .decorate(isSelected: num == selection)
                                .id(num)
                                .tag(num)
                                .onTapGesture(perform: {
                                    withAnimation {
                                        selection = num
                                    }
                                })
                        }
                    }
                    .onChange(of: selection, perform: { index in
                        withAnimation {
                            proxy.scrollTo(index, anchor: .center)
                        }
                    })
                }
            }
        }
    }
}

struct TabItem: ViewModifier {

    let isSelected: Bool

    @ViewBuilder
    func body(content:Content) -> some View {
        if isSelected {
            content
                .foregroundColor(.white)
                .background(Color.blue)
                .clipShape(Capsule())
        } else {
            content
                .foregroundColor(Color(.label))
        }
    }
}

extension View {
    func decorate(isSelected: Bool) -> some View {
        self.modifier(TabItem(isSelected: isSelected))
    }
}

注目すべき点は、TabViewとScrollViewで構成されている点です。 PageTabViewStyle(indexDisplayMode: .never)を指定することでデフォルトのページング用のメニューを消し、ScrollViewでオリジナルのページング用のメニューを用意しています。 これらを単一のデータソース@State private var selectionでBindすることによって、TabView(PageView)のスワイプによるページ遷移とScrollViewの選択中アイテムの変更を相互に反映することが可能となります。

@Stateの存在によって、WWDCのセッションでも特に強調されていたSource of Truthを意識しやすいのがUIKitの頃と比べてとてもいいですね。設計の段階でViewのどこが状態を握って管理するかを意識して構築しやすい点が非常に魅力的だと感じました。

CustomPageTabViewNav

実際に実行した結果です。 表示コンテンツに合わせて選択しているセクションメニューが中央に来るようにアニメーションスクロールが実現できていますね!

ToolbarItem

NavigationBarやToolBarをカスタマイズするための汎用的なAPIも用意されました。 OS側がどこに配置するかを決定し、コードで明示的に指定することも可能です。 日経のようなNavigationBarでセクションの切り替えボタンをつけようとすると、以下のようになります。

import SwiftUI

@main
struct NKApp: App {

    let contents: [String] = ["トップ", "経済・政治", "ビジネス", "マーケット", "テクノロジー", "国際・アジア", "スポーツ", "社会", "地域"]

    @State private var isPresented = false

    @State private var navTitle = "トップ"

    var body: some Scene {
        WindowGroup {
            NavigationView {
                ZStack {
                    Color.gray
                    Text("コンテンツ")
                        .navigationBarTitleDisplayMode(.inline)
                }
                .toolbar {
                    ToolbarItem(placement: .principal) {
                        VStack {
                            Text("速報")
                                .font(.headline)
                            Button {
                                isPresented.toggle()
                            } label: {
                                HStack {
                                    Text(navTitle)
                                    Image(systemName: "arrowtriangle.down.fill")
                                }
                                .font(.subheadline)
                                .foregroundColor(.primary)
                            }
                        }
                    }

                }
                .fullScreenCover(isPresented: $isPresented) {
                    NavigationView {
                        List {
                            ForEach(contents, id: \.self) { content in
                                Button {
                                    isPresented.toggle()
                                    navTitle = content
                                } label: {
                                    HStack {
                                        Image(systemName: "arrowtriangle.down.fill")
                                        Text(content)
                                    }
                                }
                            }
                        }
                        .navigationBarTitle("速報", displayMode: .inline)
                        .toolbar {
                            ToolbarItem(placement: .navigationBarLeading) {
                                Button {
                                    isPresented.toggle()
                                } label: {
                                    Image(systemName: "xmark")
                                }
                            }
                        }

                    }
                }
            }
        }
    }
}
ToolBarItem

Workaround

ボタンタップ時にsheet(isPresented:onDismiss:content:)を使うと、sheetを閉じたあと二度目以降のNavBarのボタンのタップが正常に動かない現象が確認されています。 そのため、ここではfullScreenCover(isPresented:onDismiss:content:)を使用する回避策を紹介します。

また、toolBarのアイテム内で、Labelを用いると、テキストが省略されて画像のみ表示されてしまう挙動も確認しています。 解決法をご存じの方がいましたら共有いただけると助かります。

まずはWidgets / App Clipsから?

バグをいくつか紹介したように、アニメーションやプレゼンテーションには難を感じる要素がまだ多いように感じます。 iOS 14から追加されたWidgetsやApp Clipsのような、単体Viewのみを表示することや、1つの体験にフォーカスしたミニアプリ程度のスケールで作ってみることを検討してみるといいのではないでしょうか。

まとめ・感想

日経電子版アプリのトップのUIをSwiftUI 2.0で再現してみつつ、新たに追加された便利なAPIをピックアップして紹介してみました。 SwiftUI 2.0からは、コンポーネントが更に追加されて本格的なレイアウトが可能になっています。 ただし、SwiftUIのバグに対応するWorkaroundが必要になる機会は、まだ多いです。

画面遷移・アニメーションに関してはまだ適切な実装、表現力に難を感じる要素が多かったです。 例えば、NavigationLinkに設定するdestinationViewは遷移元のViewを生成するタイミングで即時生成されてしまうため、実際にタップされるまで遅延させるにはどう実装すべきか、などです。 APIも大きく変化するかもしれません。

SwiftUIを使うとSwiftで型安全にUIを書けます。 これによりUI変更への抵抗感が和らぐという点でも素晴らしいと思いました。

以上です。明日は2日目、山崎さんによる「アプリ内課金の機能比較・定期購入編」です。お楽しみに!

髙木豪
ENGINEER髙木豪

Entry

各種エントリーはこちらから

キャリア採用
Entry
新卒採用
Entry
短期インターン
Entry