やりたいこと
UIKitで実装されていた「SFSafariViewControllerを使って、Webページを閲覧する」実装をSwiftUIに移行していく.
表示するWebの情報は一覧で表示されており、その一覧はモーダルで表示されている.
SFSafariViewControllerを表示するときは、プッシュ遷移させる.
結論
- UIViewControllerRepresentableを使うと、NavigationBarが2つ表示されてしまう
- UIKitのUINavigationController(EmbedされてるViewController)から、presentしてあげる(要はSwiftUIを諦める)
環境
2022年5月8日時点
Xcode 13.3.1
Swift 5.6
Simulatorでの確認
UIKitにSwiftUIを埋め込む形での使用
これ以外の環境については特に検証してないのでわからないです.
要件の整理
Page A: UIWindowのRootViewController(もしくは、そのchildren).
Page List: Page Webに渡すURLの一覧を表示. Page Aからモーダル遷移.
Page Web: Page Listで一覧されてるURLを貰いWebページを表示. Page Listからプッシュ遷移.
また、SafariViewと表現するときは、SFSafariViewControllerをUIViewControllerRepresentableをつかってSwiftUIから呼び出せるようにしてものと認識してください. UIKitから呼び出す際には、SFSafariViewControllerと表現します.
SafariView
UIViewControllerRepresentableによってSFSafariViewControllerをSwiftUIから呼び出しできるようにする実装.
「SwiftUI SFSafariViewController」とかで検索するといっぱいヒットするので、調べてみてください.
import SwiftUI
import SafariServices
struct SafariView: UIViewControllerRepresentable {
typealias UIViewControllerType = SFSafariViewController
var url: URL
func makeUIViewController(context: UIViewControllerRepresentableContext<SafariView>) -> SFSafariViewController {
return SFSafariViewController(url: url)
}
func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext<SafariView>) {
}
}
また、 BetterSafariViewというOSSがあって、要件次第では検討してもいいかもしれません.
※ 初稿時点では実際に触ったりしてないので、所管とかわからないのと、今回の要件が満たせるのかとか把握してないです.もし触ったら、感想等追記する予定.
実験パターン
- Page ListをNavigationView(SwiftUI)、直接Page Aから呼びだす.Page WebはSafariViewで実装.
- Page ListをList(SwiftUI)とNavigationLink(SwiftUI)、それをUINavigationControllerのrootViewControllerにしてPage Aから呼び出す.Page WebはSafariViewで実装.
- 2のPage WebをSFSafariViewControllerで実装し、遷移をDelegate等を使ってUINavigationControllerで処理.
本記事の結論は3になります.
1. Page ListをNavigationView(SwiftUI)、直接Page Aから呼びだす.Page WebはSafariViewで実装.
イメージ図(逆にわかりづらい?コードもあるのでみてください)
実装
// PageAViewController.swift
class PageAViewController: UIViewController {
// 真っ白の画面の中央にボタンを置くとかでやってます
@IBAction func showList(_ sender: Any) {
let pageList = PageList.init()
let host = UIHostingController.init(rootView: pageList)
show(host, sender: nil)
}
}
// PageList.swift
struct PageList: View {
let urls = ["https://apple.com", "https://www.google.com/", "https://www.microsoft.com/"]
var body: some View {
NavigationView {
List.init(urls, id: \.self) { url in
NavigationLink(destination: SafariView(url: URL.init(string: url)!)) {
Text(url)
}
}
}
}
}
結果
NavigationBarが表示されており、さらにその下にSafariViewのNavigationBarも表示される.
「完了」ボタンを押したらモーダルが閉じられてしまいます(Page Aまで戻ってしまう).
なんなら、どうしてこんなに余白ができるんだっていうくらい余白があります.
2. Page ListをList(SwiftUI)とNavigationLink(SwiftUI)、それをUINavigationControllerのrootViewControllerにしてPage Aから呼び出す.Page WebはSafariViewで実装.
実装
// PageAViewController.swift
class PageAViewController: UIViewController {
// 真っ白の画面の中央にボタンを置くとかでやってます
@IBAction func showList(_ sender: Any) {
let pageList = PageList.init()
let host = UIHostingController.init(rootView: pageList)
let navi = UINavigationController.init(rootViewController: host)
show(navi, sender: nil)
}
}
// PageList.swift
struct PageList: View {
let urls = ["https://apple.com", "https://www.google.com/", "https://www.microsoft.com/"]
var body: some View {
List.init(urls, id: \.self) { url in
NavigationLink(destination: SafariView(url: URL.init(string: url)!)) {
Text(url)
}
}
}
}
結果
先ほどより、良くなりましたね. ただ、相変わらず2つ表示されてしまいますし、「完了」ボタンを押したらモーダルが閉じられてしまいます.
あと、「完了」ボタンのタップ判定の位置が、少し変になってました.
余談ですが、UINavigationControllerに入ってればNavigationViewに入ってないNavigationLinkで遷移できるのは、ちょっとビックリしました.
実験1と2までで困ること
- SafariViewのNavigationBarと、PageListのNavigationBarの2つ表示されてしまう.
- 「完了」ボタンが押されたときに、モーダルが閉じられてしまう.
前者の問題、実はNavigationBarを非表示にする処理を入れることで解決します.
// PageList.swift
//・・・
NavigationLink(
destination: SafariView(url: URL.init(string: url)!)
.navigationBarHidden(true)) // 遷移先のSafariViewのNavigationBarを非表示にする
//・・・
ここまでで私は「完了」ボタンが押されたときに、モーダルを閉じるじゃなくてプッシュバックするようにハンドリングしなおせばいける?っと考えました.
しかし、残念ながら現時点では「完了」ボタンの処理を変更することはできないようです.
ref: Apple Developer Document - SFSafariViewController
2のPage WebをSFSafariViewControllerで実装し、遷移をDelegate等を使ってUINavigationControllerで処理.
イメージ図
Delegateの結果を受け取るために、EmbedViewControllerが追加されてる.
実装
// PageAViewController.swift
class PageAViewController: UIViewController {
// 真っ白の画面の中央にボタンを置くとかでやってます
@IBAction func showList(_ sender: Any) {
let embed = EmbedViewController.init(nibName: nil, bundle: nil)
let navi = UINavigationController.init(rootViewController: embed)
show(navi, sender: nil)
}
}
class EmbedViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let pageList = PageList.init(delegate: self)
let host = UIHostingController.init(rootView: pageList)
addChild(host)
// ・・・ ViewControllerをEmbedする処理, 長くなるので割愛
}
}
// PageList.swift
struct PageList: View {
let urls = ["https://apple.com", "https://www.google.com/", "https://www.microsoft.com/"]
var delegate: PageListDelegate? = nil
var body: some View {
List.init(urls, id: \.self) { url in
Text(url)
.onTapGesture {
delegate?.pageList(didSelectRow: URL.init(string: url))
}
}
}
}
// Delegate関連
import SafariServices
protocol PageListDelegate {
func pageList(didSelectRow url: URL?)
}
extension EmbedViewController: PageListDelegate {
func pageList(didSelectRow url: URL?) {
let vc = SFSafariViewController.init(url: url!)
present(vc, animated: true)
}
}
結果
NavigationBarが2つ表示されない.
「完了」ボタンを押してプッシュバックの遷移になる(モーダルが閉じられない).
やってることとしては、UIKitで実装してSwiftUIで呼び出してるだけなので、できて当たり前といえば当たり前です.
余談
- Delegateの処理で
present
で処理してるが、navigationController?.pushViewController(vc, animated: true)
で遷移すると、実験2と同じような表示になります. - SwiftUIにUIKitの
present
相当のものがあるとうまくいくのかなって思ったのですが、現状は見つけられませんでした.
まとめ
最終的にはUIKitに遷移処理を任せる形で実装することになりました.
タイトルが SwiftUIでSFSafariViewControllerをプッシュ遷移させたかった
と過去形になってるのは、そういう理由です.
UIKitにできて、SwiftUIにできないことが多い間は、こういう実装が増えるのかなーなんて思ってたりします.
個人的には fullScreenCover
とかでモーダルさせれば、正直気にならないので無理に既存の仕様に合わせるのかは、チームと相談していけると良さそうです.
実際にこのコードが通用し続けるのか、自信はありません.SwiftUIの変更や、仕様が複雑化していくことで、対応できなくなる怖さもあります.
もっと良い実装があれば、教えていただけると嬉しいです.