スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

SwiftUIで対応しきれずUIKitを使ったコンポーネントのまとめ

こんにちは、iOSエンジニアの @elliekwon です。去年のiOSDC Japan 2021「スタディサプリ」がFull SwiftUIを選択した先に見えてきたものの発表で紹介させて頂いた通り、SwiftUIで開発してきた「スタディサプリ中学講座」ですが、SwiftUIでは対応しきれず、ごく一部UIKitを利用した機能も存在しています。この記事ではそれらのケースと対応策を紹介します。

事前知識

その前にSwiftUIからUIKitを使うため、先に知って貰えたいものがあります。 一つは UIViewRepresentable / UIViewControllerRepresentable と言うprotocolで、これらはSwiftUIでUIKitのUIView/UIViewControllerを使えるようにしてくれるwrapperです。 UIViewRepresentableに準拠したSwiftUIのView内で、makeUIView()/updateUIView()の中にUIKitのコードを実装することで、SwiftUI上でもUIKitの機能を使うことができます。 UIViewControllerRepresentableも同様にmakeUIViewController()/updateUIViewController()内でUIViewControllerを実装することで利用できます。

もう一つはイベントを管理するクラスであるCoordinatorです。Coordinatorを通してUIKitで行われたイベントをSwiftUIへ伝えることができます。

シェアメニューを表示したい:UIActivityViewController

スタディサプリ中学講座」では講座のテキストや解答冊子などを保存し印刷して勉強できる機能を提供しています。そのためにシェアメニューを表示していますが、シェアメニューを表示するSwiftUIの部品は存在しないため、UIKitのUIActivityViewControllerを使う必要があります。 下記はUIViewControllerRepresentableを利用してカスタムビューとして作ったActivityViewControllerのコードになります。

struct ActivityViewController: UIViewControllerRepresentable {
  var activityItems: [Any]
  var applicationActivities: [UIActivity]?

  func makeUIViewController(context: Context) -> UIActivityViewController {
    return UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities)
    return controller
  }

  func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

使う側ではActivityViewControllerをsheetのcontentとして渡すだけでオーケーで割と少ないコードで利用可能です。

ContentView()
  .sheet(isPresented: $shouldShowActivityViewController, content: {
    ActivityViewController(activityItems: [url as Any])
  })

この度我々の実装ではiPadiOSと同様にフルスクリーンで表示しておりますが UIActivityViewControllerのドキュメントにを見ると実はiPadはpopover,iPhoneはモーダルで分け出すように書かれています。そう言うことでiPadの件は今後の課題ですね。

Safari機能のあるウェブビューを表示したい:SFSafariViewController

SFSafariViewControllerとは、アプリがSafariのような標準インタフェースからページを表示できるようにアップルが iOS 9 から提供している部品です。しかし、まだSwiftUIではSFSafariViewController相当の機能が提供されていません。そう言うことで今回もUIViewControllerRepresentableを通してSafariViewControllerを作り出します。

private struct SafariViewController: UIViewControllerRepresentable {
  let url: URL

  func makeUIViewController(context: Context) -> SFSafariViewController {
    let safariView = SFSafariViewController(url: url)
    safariView.delegate = context.coordinator
    return safariView
  }

  func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
  }
}

利用規約や諸々のウェブビューをアプリ内で開く際に活躍しています。

アプリ内ウェブビューでページを表示したい:WKWebView

Safariと同じユーザー体験を提供するSFSafariViewControllerとは違ってウェブビューを直接触りたい時に使われるのがWKWebViewですよね。 「スタディサプリ中学講座」ではプラットフォームごとに差分の無い学習体験をしてほしい思いでWKWebViewを有効に活用しています。 下記のコードは一部makeUIViewの一部のみ取ってきたものでこれだけでは動きませんが、大きな枠では他のパターンとやることが違いのないことを感じて頂ければ結構です。

struct WebView: UIViewRepresentable {
  var request: URLRequest
  
  func makeUIView(context: Context) -> WKWebView {
    let webView = WKWebView(frame: .zero, configuration: config)
    webView.navigationDelegate = context.coordinator
    context.coordinator.setUpSubscriptions(inputs: inputs, uiView: webView)
    webView.load(inputs.request)
    return webView
  }
  
  func updateUIView(_ uiView: WKWebView, context: Context) { }
  
  func makeCoordinator() -> WebView.Coordinator {
    Coordinator(self)
  }
}

extension WebView.Coordinator: WKNavigationDelegate {
  func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
    parent.eventSubject.send(.start)
  }

  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    parent.eventSubject.send(.finish)
  }
  (...以下省略)
}

スタディサプリ中学講座」ではWKWebViewのデリゲートの受け取りはCoordinatorを通して実装しています。

複雑なアラートを表示したい:UIAlertController

スタディサプリ中学講座」では学習途中の講座に再び入ると続きから再開するか、または最初から始めるかを選ぶことができます。実はこのアラート、最初はactionSheetでデザインされていたものなんですが、なぜUIAlertControllerを使うことにしたのかご説明します。

iPadでは.actionSheetを出すと見た目が良くない

iPadでは .actionSheet を使う場合、起点のViewの場所を指定すると画面上で一応出すことはできますが、見た目的にあまり綺麗に見えませんでした。

actionSheetの代わりにalertへ

デザイナーさんとの相談の上actionSheetからalertに変えることにしましたが、このalertの実装にはiOSバージョンごとに差分ありました。

iOS 14とiOS 15の両方を満たすためのUIAlertController

実はiOS 15からは.alertmodifierでとても簡単にアラートを出すことができます。iOS 15からサポートするアプリならばこちらのドキュメント-8584l)を是非参考にしてください。 「スタディサプリ中学講座」の現状のミニマムバージョンはiOS 14でして両OS共に対応できる方法を工夫する必要がありました。それでmodifierを使わずにまたUIViewControllerRepresentableを利用することにしました。

複数のボタンのアクションの処理で多少長くなりますがコードはこちらです。

struct AlertView: UIViewControllerRepresentable {

  @Binding var isPresented: Bool

  var title: String
  var message: String
  var actions: [(title: String?, style: UIAlertAction.Style, completionHandler: () -> Void )]

  func makeCoordinator() -> AlertView.Coordinator {
    Coordinator(self)
  }

  class Coordinator: NSObject {

    var alert: UIAlertController?

    var control: AlertView

    init(_ control: AlertView) {
      self.control = control
    }
  }

  func makeUIViewController(context: UIViewControllerRepresentableContext<AlertView>) -> UIViewController {
    UIViewController() // UIAlertControllerを表示するコンテナ
  }

  func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<AlertView>) {
    if isPresented {
      let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
      context.coordinator.alert = alert

      actions.forEach { action in
        alert.addAction(UIAlertAction(title: action.title, style: action.style, handler: { _ in
          action.completionHandler()
          alert.dismiss(animated: true) {
            self.isPresented = false
          }
        })
        )
      }

      DispatchQueue.main.async {
        uiViewController.present(alert, animated: true, completion: {
          self.isPresented = false
          context.coordinator.alert = nil
        })
      }
    }
  }
}

使い方は下記のようになります。

    AlertView(isPresented: $isPresented, title: "続きから再開しますか?", message: "", actions: [
        (
          title: "続きから再開する",
          style: UIAlertAction.Style.default,
          completionHandler: {
            viewModel.loadResumeSession()
          }
        ),
        (
          title: "最初から始める",
          style: UIAlertAction.Style.default,
          completionHandler: {
            viewModel.loadNewSession()
          }
        ),
        (
          title: "キャンセル",
          style: UIAlertAction.Style.cancel,
          completionHandler: {
            viewModel.closeAction()
          }
        ),
      ])

学習の開始と再開を選択するアラートとしてよく働いてくれています。

Alert

ちなみにこのコードは先述の通りアプリのミニマムバージョンがiOS 15に上がり次第.alertに差し替える予定のためFIXMEとして管理しています。

SecureFieldでもフォーカスしたことを検知したい:UITextField

SwiftUIのTextFieldやSecureFieldはiOS 13から提供されてたのでなぜこれがサポートできない一覧に含まれているのか気になる方もいらっしゃると思います。

先に結論から言うと、iOS 14では、SwiftUIのSecureFieldはフォーカスされた際にそれを検知することができないからです。

InputBox

スタディサプリ中学講座」はユーザーが今どのTextFieldを入力しているかを分かりやすくする為にフォーカスされるとそのボーダーに色を塗っていてその検知の有無は大事でした。 そこで、UIViewRepresentableを利用してUITextFieldをラップしたSecureableTextFieldを共通利用可能なUIコンポーネントとして実装し、Secureな場合でも、そうでない場合でも利用出来るようにしました。

長くなってしまうので一部分のみですが、以下のようなコードになります。

struct SecureableTextField: UIViewRepresentable {
  @Binding var isFocused: Bool
  let isSecureTextEntry: Bool

  func makeUIView(context: Context) -> UITextField {
    let textField = UITextField()
    textField.delegate = context.coordinator
    textField.isSecureTextEntry = isSecureTextEntry
    return textField
  }

  func updateUIView(_ uiView: UITextField, context: Context) {
    DispatchQueue.main.async {
      if isFocused {
        // フォーカスされた際にキーボードを表示する
        uiView.becomeFirstResponder()
      } else {
        uiView.resignFirstResponder()
      }
    }
  }
}

extension SecureableTextField {
  func makeCoordinator() -> Coordinator {
    Coordinator(self)
  }

  // UITextFieldDelegateを実装
  final class Coordinator: NSObject, UITextFieldDelegate {
    var parent: SecureableTextField

    init(_ control: SecureableTextField) {
      self.parent = control
      super.init()
    }

    func textFieldDidBeginEditing(_ textField: UITextField) {
      if !parent.isFocused {
        parent.isFocused = true // 編集開始時にフォーカスを当てる
      }
    }

    func textFieldDidEndEditing(_ textField: UITextField) {
      if parent.isFocused {
        parent.isFocused = false // 編集終了時にフォーカスを解除する
      }
    }
  }
}

ちなみに、我々はiOS 14をサポートするためにこんな工夫をしていましたが、iOS 15からはFocusStateと言うラッパーを使うことで解決になります。

SwiftUIを利用して直接NavigationBarやTabBarの色を変更することは出来ないため、代わりにUINavigationBar/UITabBarのappearanceを利用して変更しています。

スタディサプリ中学講座」もRootScreenの1箇所でappearance系の指定をまとめて設定しています。

// 基本ナビゲーションバーの背景色を白に設定
let navigationBarAppearance = UINavigationBarAppearance()
navigationBarAppearance.backgroundColor = .white
UINavigationBar.appearance().standardAppearance = navigationBarAppearance

// 下部のタブバーの背景色を白に設定
let tabBarAppearance = UITabBarAppearance()
tabBarAppearance.backgroundColor = .white
UITabBar.appearance().standardAppearance = tabBarAppearance
if #available(iOS 15.0, *) {
  // scrollEdgeAppearanceはiOS 15からサポートされる属性で、これが設定されないとタブバーの上のボーダーが見えなくなる
  UITabBar.appearance().scrollEdgeAppearance = tabBarAppearance
}

短いコードで済むのは良いものの、もし画面ごとに異なるパターンがあるのならば面倒かも知れません。その場合にはappearanceを変更するViewModifierを作り出して、Viewから必要なNavigationBarスタイルを呼び出すこともありかと思います。だだしこれはグローバルな変更をやってしまうので、仕組みによってはその画面で欲しくないナビゲージョンが出ちゃったとかのバグも起こりやすいので注意が必要ですね。

まとめ

SwiftUIでサポートされていない機能に対する解決は UIViewRepresentableを継承しmakeUIViewからUIKitの欲しい部品を作り出してリターンする が現在の時点での唯一の方法のようです。しかしまだまだ進化中のSwiftUIですのでより快適に使えるようになることを期待しています。

We’re hiring!

スタディサプリでは、世界の果てまで最高の学びを共に届ける仲間を募集しています。 少しでも気になった方はカジュアル面談もやっていますのでお気軽にお問い合わせください!

参考資料