- 投稿日:2021-03-01T22:31:45+09:00
【iOS】コードから端末のSizeClassを判断する(Swift)
SizeClassとは
SizeClassとは、端末の縦横のサイズをそれぞれ
Compact
またはRegular
として指定し、その組み合わせによってレイアウトの変更ができるようにするための機能。
Compact
が小さいサイズのときに使われ、Regular
が大きいサイズとして使われます。
組み合わせは全部で4通りあります。
SizeClass(Full) SizeClass(略) 主な機種 Compact/Compact CC iPhone8の横画面、iPhone12の横画面 Compact/Regular CR すべてのiPhoneの縦画面 Regular/Compact RC iPhone8Plusの横画面、iPhone11の横画面など Regular/Regular RR iPad(フルスクリーン)の縦画面、横画面 より詳しいパターンについては、公式ドキュメントをご参照ください。
https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/どうやって使うの?
StoryBoardから使う
簡単に説明すると、
SizeClassによって、制約を有効にしたり、Viewの表示非表示を切り替えることができます。
例1)ImageViewの高さを、SizeClassが「RR」のときは100px、「CR」のときは、60pxにするといったようなことができる。
例2)LabelをSizeClassが「RR」のときは非表示、「CR」のときは表示するといったようなことができる。詳しい使い方については、下記の記事がわかりやすいと思いましたので、リンクを貼らせていただきます。
https://qiita.com/wnstjd0333/items/1c2ed3cec565f69cbb90コードで使う
「UITraitEnvironment」プロトコルを実装しているクラスにおいて、
traitCollection
プロパティが利用可能です。
代表例として、UIViewControllerは「UITraitEnvironment」プロトコルを採用しているため、traitCollection
が利用できます。また、端末の回転やマルチタスキングへの切替によって、端末のSizeClassに変化があった場合、
traitCollectionDidChange(_:)
というメソッドが呼び出されます。(viewWillTransition(to:with:)
も呼ばれます。)
実際に使用したい場合は、どちらかのメソッドをオーバーライドして個々のViewControllerで利用していく感じです。それらを活用して、現在の画面が、4パターンあるSizeClassのうちどれに当てはまるかをコード上から検知することができます。
この記事は、タイトルにもあるように、SizeClassの判断をコードで行うことが目的ですので、以下に書いていきます。
SizeClassパターンの検知
おそらく、マルチデバイス対応、マルチタスキング対応を実施しようとするとすべてのパターンが必要になると思いますので、4パターンすべてを判定するロジックを載せておきます。
ViewController.swiftenum SizeClass: String { // width/height case CC = "Compact/Compact" case CR = "Compact/Regular" case RC = "Regular/Compact" case RR = "Regular/Regular" } class ViewController: UIViewController { // 色々と省略 // viewWillTransitionでも代用可能です。previousTraitCollectionを使いたければ、traitCollectionDidChangeのが最適 override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { // SizeClassが変わった時に呼ばれるので毎回チェックしたい場合は、ここでチェックするメソッドを呼び出す。 let sizeClass: String = returnSizeClass().rawValue print(sizeClass) // SizeClassが変わった時に、処理を書きたい場合はここに書く } func returnSizeClass() -> SizeClass { if traitCollection.horizontalSizeClass == .regular && traitCollection.verticalSizeClass == .regular { return .RR } else if traitCollection.horizontalSizeClass == .regular { return .RC } else if traitCollection.verticalSizeClass == .regular { return .CR } else { return .CC } } }enumで4パターンを定義しておいて、画面の状態によって当てはまるSizeClassをリターンするという方法で実装しました。
おそらく、ご自身のViewControllerにこちらをコピペしていただくだけでも動くと思います。
読んでいただいた方のお役に立てれば幸いです。
- 投稿日:2021-03-01T22:03:13+09:00
[Swift5]スクロールでNavigationBarを隠す方法(シンプル)
やりたいこと
YouTubeなどでよく目にする、スクロールでタイムラインの下部に移動する際にNavigationbarも同時に画面上部に隠す動作を実装したいと思います。
今回紹介する方法は非常にシンプルです。
他にも最適解のような方法があるとは思いますが、とにかくNavigationBarを隠せれば問題ないという方は是非参考にしてみてください。実装方法
// スクロールでナビゲーションバーを隠す func scrollViewDidScroll(_ scrollView: UIScrollView) { if scrollView.panGestureRecognizer.translation(in: scrollView).y < 0 { navigationController?.setNavigationBarHidden(true, animated: true) } else { navigationController?.setNavigationBarHidden(false, animated: true) } }以上です。
- 投稿日:2021-03-01T18:20:11+09:00
【SwiftUI】alertメソッドでアラートを出す
先日リリースした私のアプリに使用した技術をひとつずつ解説しています。
私のアプリはこちら。
alertとは
下のようなアラートを出すメソッド。iPhoneを使っているといろんな場面で見かけるかと思います。
実際の動作
このように、ボタンを押すとアラートが出るサンプルアプリを作りました。こちらについて解説します。
基本的な書き方
まず、alertメソッドの基本的な書き方を説明します。
alertメソッド.alert(isPresented: ブール値, content: { Alert(アラートの内容) })引数の
isPresented
がtrueになったときにアラートが出現します。
アラートの内容はcontent
に書き、Alert構造体
を使います。サンプルアプリのコードについて解説します。
ソースコード
ContentView.swiftimport SwiftUI struct ContentView: View { @State var goodAlert = false //いいね!ボタンの方のアラートを出すためのブール値 @State var isGood = false //サムズアップに色をつけるかどうかのブール値 @State var noGoodAlert = false //よくないね!ボタンの方のアラートを出すためのブール値 var body: some View { VStack{ // サムズアップのマーク。isGoodがtrueになると色がつく。 Image(systemName: isGood ? "hand.thumbsup.fill":"hand.thumbsup") .font(.title) .foregroundColor(isGood ? .pink:.none) .padding() // いいね!ボタン Button(action: { goodAlert = true // このアクションでアラートを呼び出す }, label: { buttonLabel(text: "いいね!", fieldColor: Color.pink) }) .padding() // goodAlertがtrueになると呼び出されるアラート .alert(isPresented: $goodAlert, content: { Alert( title: Text("いいね!しますか?"), message: Text("いいね!すると色がつきます。"), primaryButton: .default(Text("はい"), action: {isGood = true}), secondaryButton: .destructive(Text("いいえ"), action: {isGood = false}) ) }) // よくないね!ボタン Button(action: { noGoodAlert = true // このアクションでアラートを呼び出す }, label: { buttonLabel(text: "よくないね!", fieldColor: Color.blue) }) .padding() // noGoodAlertがtrueになると呼び出されるアラート .alert(isPresented: $noGoodAlert, content: { Alert(title: Text("そんなこと言わないで")) }) } } } struct buttonLabel: View { let text: String let fieldColor: Color var body: some View { Text(text) .font(.title) .foregroundColor(.white) .frame(width: 250, height: 100) .background(fieldColor) .cornerRadius(30) } }全体のソースコードはこのようになっています。
少し複雑なので、いいね!ボタンの方に焦点を当てて説明したいと思います。いいね!ボタンとアラートの仕組み
まず、ContentViewのプロパティとしてブール型の変数を用意しています。
@State var goodAlert = false //いいね!ボタンの方のアラートを出すためのブール値 @State var isGood = false //サムズアップに色をつけるかどうかのブール値いいね!ボタンのアラートを出すための変数
goodAlert
は初期値としてfalseを入れています。初期値がtrueの場合、アプリ起動時にアラートが表示されることになります。次に、
goodAlert
がtrueに反転するためのトリガーを書きます。Button(action: { goodAlert = true // このアクションでアラートを呼び出す }, label: { buttonLabel(text: "いいね!", fieldColor: Color.pink) })今回はボタンのアクションによってアラートを呼び出したいので、このようになります。
そして、呼び出すアラートがこちら。
.alert(isPresented: $goodAlert, content: { Alert( title: Text("いいね!しますか?"), message: Text("いいね!すると色がつきます。"), primaryButton: .default(Text("はい"), action: {isGood = true}), secondaryButton: .destructive(Text("いいえ"), action: {isGood = false}) ) })
isPresented
にgoodAlertを入れているので、goodAlertがtrueに反転したときにcontent
に書かれているアラートが出現します。このように書くことで出現するアラートがこちらです。
こうして見ると、Alert構造体のどの引数がどの部分に表れているかわかりやすいかと思います。このアラートの「はい」ボタンを押すことで
isGood
にtrueが、「いいえ」ボタンを押すことでfalseが入ります。そうすることで結果的にサムズアップのマークに色がついたり消えたりしているのです。よくないね!ボタンとアラートの仕組み
次に、よくないね!ボタンの方の説明をしていきます。
こちらもまずはブール型の変数を用意しています。@State var noGoodAlert = false //よくないね!ボタンの方のアラートを出すためのブール値こちらの変数を反転させるトリガーがこちら。
Button(action: { noGoodAlert = true // このアクションでアラートを呼び出す }, label: { buttonLabel(text: "よくないね!", fieldColor: Color.blue) })呼び出すアラートはこちら。
.alert(isPresented: $noGoodAlert, content: { Alert(title: Text("そんなこと言わないで")) })いいね!ボタンの方のアラートに比べるとすっきりしています。実は、Alert構造体はtitleさえあればアラートとして機能します。
実際に出現するアラートがこちら。
Alert構造体にはタイトルしか書いていませんが、自動的にOKボタンをつけてくれます。このボタンを押すと
isPresented
がfalseになり、アラートが消えます。(いいね!ボタンの方も同様の仕組みでアラートが消えています)アラートのボタンについて
先ほど述べたように、Alert構造体はtitleがあれば機能し、アラートを消すボタンを自動的につけてくれますが、どのようなボタンにするかを指定することができます。
アラートのボタンはひとつ、もしくはふたつ付けることができます。それぞれ使用する引数が違うので、別々に解説したいと思います。
ボタンがひとつの場合
先ほどのよくないね!アラートを次のように書きかえます。
.alert(isPresented: $noGoodAlert, content: { Alert(title: Text("そんなこと言わないで"), dismissButton: .default(Text("了解です"))) })
dismissButton
という引数を追加しました。これによりボタンのテキストが変わります。「了解です」ボタンを押すとアラートが消えてくれます。このボタンに機能を追加したい場合、
.default
の引数のaction
に書けば追加できます。ボタンがふたつの場合
いいね!のアラートをもう一度見てみましょう。
.alert(isPresented: $goodAlert, content: { Alert( title: Text("いいね!しますか?"), message: Text("いいね!すると色がつきます。"), primaryButton: .default(Text("はい"), action: {isGood = true}), secondaryButton: .destructive(Text("いいえ"), action: {isGood = false}) ) })
primaryButton
がボタンひとつめ、secondaryButton
がふたつめです(そのままですね)。
primaryButtonでは.default
、secondaryButtonでは.destructive
というメソッドが使われています。実はこれらがボタンの文字の色を決めています。「はい」ボタンの方はdefault、つまりデフォルトの色の青になります。destructiveとは「破壊的な」という意味があり、これを使った「いいえ」ボタンは文字が赤色になっています。これらふたつを使い分けることで、ユーザーの押し間違いを減らす効果が期待できます。ぜひ使い分けましょう。
まとめ
alertメソッドはおそらく、iPhoneを触っていれば見ない日はないほど頻繁に見る機能かと思います。使うシーンもさまざまなので、ぜひこの機能を応用してアプリに取り入れてみてください。
- 投稿日:2021-03-01T14:26:35+09:00
Swift:メモ RSSを使ったテーブルアプリ
import UIKit class ListViewController: UITableViewController,XMLParserDelegate{//uitableviewcontrollerはdatasourceやdelegateも批准している var parser:XMLParser!//rssデータを解析するためのXMLParserクラスのインスタンスを格納するためのプロパティ var items = [Item]()//複数の記事を格納するための配列 var item:Item?//Itemクラス型のプロパティ Itemクラスはタイトルと本文のURLの2つのプロパティを持つ var currentString = "" //セルの個数 override func tableView(_ tableView:UITableView,numberOfRowsInSection section:Int) -> Int { return items.count } //tableView:cellForRowAtはセルの内容をiPhoneに知らせるメソッド セルの内容を戻り値に設定 //indexPathにはtableView:cellForRowAtメソッドが設定を行っているセルの行番号が保持されている //IndexPathにはsectionとrowのプロパティが含まれている override func tableView(_ tableView: UITableView,cellForRowAt indexPath: IndexPath) -> UITableViewCell{ let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) cell.textLabel?.text = items[indexPath.row].title //瞬時に表示するために大量のセルを作成しなくて済むようにセルの再利用を設定322 //dequeueReusableCell(withIdentifier:for)メソッドを使う場合、引数にどのセルを再利用するか指定する→セルを選択した状態でのIdentifier項目の名前Cellが第一引数 return cell } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) startDownload() } //データのダウンロード startDownload()はオリジナルのメソッド func startDownload(){ self.items = []//itemsを空にする、古いデータが残ったままダウンロードすると重複する記事が生まれてしまう if let url = URL( string: "https://aaaaaa.com/rssfeeder/"){//ニュース記事のあるwebサイトのURLを指定 if let parser = XMLParser(contentsOf: url)//parserのインスタンスを作成 前の行で指定したURLを引数に 不正なURLが検出された時nilを返すことがあるのでメソッドの戻り値はオプショナル型 { self.parser = parser self.parser.delegate = self self.parser.parse() //parserのデリゲートにselfを指定して最期にparserメソッドを呼び出すことでデータの解析を開始 } } } func parser(_ parser: XMLParser,didStartElement elementName: String,namespaceURI:String?,qualifiedName qName: String?,attributes attributeDict:[String : String]) //要素感の開始タグが見つかるごとに呼び出される ダウンロードされた記事から必要なデータだけ取り出す処理を行うメソッド XMLParserDelegateで宣言されているメソッド { self.currentString=""//要素を一時的に保存する変数currentStringを空に 古い文字列が入らないように if elementName == "item"{//要素名がitemの場合のみニュース記事を入れる箱itemを作る self.item = Item() } } //タグで囲まれた要素を取り出す 内容が見つかると自動的にこのメソッドが呼び出される メソッドの中に文字列を取り出す処理を書く func parser(_ parser: XMLParser, foundCharacters string: String) { self.currentString += string//引数stringには見つかった記事の内容が格納されている 引数の中身を変数currentStringに+=演算子を使って追加 この段階ではcurrentStringにどの要素名の内容が入っているかは不明 } //要素名が終わる</items>が見つかると以下のメソッドが自動的に呼び出される func parser(_ parser: XMLParser, didEndElement elementName:String,//elementNameに要素名を格納 namespaceURI:String?, qualifiedName qName:String?){ switch elementName{//switch文の条件にelementNameを指定することで要素名ごとに処理が行われる //要素名がtitleの時はitem.titleに内容を格納 linkも同じ case "title":self.item?.title = currentString case "link":self.item?.link = currentString case "item":self.items.append(self.item!)//要素がitemの場合はニュース記事の終わりを意味するのでこれまで取得したデータをitemsの中に格納 append=追加する default : break } } //テーブルビューの内容を更新することで取得した記事を画面に表示 func parserDidEndDocument(_ parser:XMLParser){ self.tableView.reloadData() } override func prepare(for segue:UIStoryboardSegue, sender:Any?){ if let indexPath = self.tableView.indexPathForSelectedRow{//ユーザーがタップしたセルのindexPathを取得 let item = items[indexPath.row]//取得した値を用いてitem配列から該当する記事を取得 let controller = segue.destination as! DetailViewController//controller定数に遷移先のビューコントローラーを格納 controller.title = item.title//titleプロパティに記事タイトルを格納 controller.link = item.link } } }
- 投稿日:2021-03-01T14:15:03+09:00
【Swift】条件に当てはまるFirebaseのドキュメントを一括削除
条件に該当するドキュメントを削除するコード
Firestore.firestore().collection("users").whereField("user_name", isEqualTo : "田中").getDocuments() { (querySnapshot, err) in if let err = err { print("Error getting documents: \(err)") } else { for document in querySnapshot!.documents { document.reference.delete() } } }このコードの場合user_nameが田中と書かれたすべてのドキュメントが削除されます
- 投稿日:2021-03-01T14:09:03+09:00
【Swift】Firebaseで認証済みユーザーかどうか振り分ける処理
最初の画面を認証済みユーザーと新規ユーザーとで切り替えたい時の処理
import UIKit import Firebase import FirebaseUI class LaunchViewController : UIViewController{ override func viewDidLoad() { super.viewDidLoad() //ロードされるまでの初期画面(ロゴ画像、ナビゲーションバーなど) } } override func viewDidAppear(_ animated: Bool) { if Auth.auth().currentUser != nil { self.performSegue(withIdentifier: "sign", sender: nil) print(認証済みユーザー) } else { self.performSegue(withIdentifier: "nosign", sender: nil) print(新規ユーザー) } } }ポイントはAuth.auth().currentUser != nilのif文
- 投稿日:2021-03-01T14:00:45+09:00
【Swift】Eurekaでボタンの表示・非表示の切り替え
まずはフォームを作成
ここでは簡略化のため表示・非表示を切り替えたいボタンのみ実装していますoverride func viewDidLoad() { super.viewDidLoad() form <<< ButtonRow("Button") {row in row.tag = "delete_row" row.title = "商品を削除する" row.onCellSelection{[unowned self] ButtonCellOf, row in self.delete() } } }次に表示・非表示を切り替えるコード
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) if let buttonRow = self.form.rowBy(tag: "delete_row") as? ButtonRow{ if delete_state == true { buttonRow.hidden = true buttonRow.evaluateHidden() } } }変数delete_stateは自分で条件に合わせて設定しましょう
- 投稿日:2021-03-01T13:54:14+09:00
【Swift】Eurekaのボタンの色を変更する方法
SwiftのライブラリEurekaでボタンの色を変更する方法に苦戦したので残しておきます。
<<< ButtonRow("Button3") {row in row.tag = "delete_row" row.title = "商品を削除する" row.onCellSelection{[unowned self] ButtonCellOf, row in self.delete() } }.cellSetup() {cell, row in cell.backgroundColor = UIColor.white cell.tintColor = UIColor.red }他にもalpha、window、backgroundviewなどがあります。
- 投稿日:2021-03-01T13:46:02+09:00
【Swift】WKWebViewをコードだけで使い回す
SwiftでUITableViewのセルごとに異なるウェブサイトに遷移させたい時に便利な使い回しコード。
まずは使い回すclassを指定
import UIKit import WebKit class WebView : UIViewController, WKUIDelegate{ var webView : WKWebView! var request_url : URL? override func loadView(){ let webConfiguration = WKWebViewConfiguration() webView = WKWebView(frame: .zero, configuration: webConfiguration) webView.uiDelegate = self view = webView } override func viewDidLoad() { super.viewDidLoad() let url = self.request_url let request = URLRequest(url: url!) webView.load(request) } }後はtableviewのcellごとにURLを指定するだけ。
if indexPath.section == 1 { if indexPath.row == 0 { //チュートリアル let vc = WebView() vc.title = "チュートリアル" vc.request_url = URL(string: "https://sampe.com/tutorial/") self.navigationController?.pushViewController(vc, animated: true) } if indexPath.row == 1 { //アプリの使い方 let vc = WebView() vc.title = "アプリの使い方" vc.request_url = URL(string: "https://sampe.com//use/") self.navigationController?.pushViewController(vc, animated: true) } if indexPath.row == 2 { //よくある質問 let vc = WebView() vc.title = "よくある質問" vc.request_url = URL(string: "https://sampe.com/question/") self.navigationController?.pushViewController(vc, animated: true) } 以下略
- 投稿日:2021-03-01T13:35:14+09:00
Firebaseのタイムスタンプ型をSwiftでDate型にキャスト
まずはFirebaseのデータを配列?リスト?として取得するクラスを作成
もともとFirebaseにはdateという名前のタイムスタンプ型のフィールドが登録されている前提import Foundation import CodableFirebase import Firebase struct Info : Decodable { //Firebaseから取得するフィールドを格納する変数を定義 var id : String? var messageId : String? var roomid : String? var senderid : String? var created_date : Date? static func decode(_ data : [String:Any]) -> Info? { do { var item = try FirestoreDecoder().decode(Info.self, from: data) if let timestamp : Timestamp = data["date"] as? Timestamp { item.created_date = timestamp.dateValue() } return item }catch { return nil } } }呼び出す時はこんな感じ。
import Foundation import UIKit import Firebase var Sample_Array: [Info] = [] var firestore = Firestore.firestore() override func viewDidLoad() { super.viewDidLoad() self.firestore.collection("info_data").getDocuments { (snaps, error) in guard let documents = snaps?.documents else { return } self.Sample_Array = [] for document in documents { guard let item = Info.decode(document.data()) else{ continue } self.Sample_Array.append(item) } print(self.Sample_Array) } }
- 投稿日:2021-03-01T13:17:08+09:00
【Swift】1画面に2種類のCollectionViewを実装する方法
まずlazy varで2種類のCollectionViewを定義
lazy var collectionView1 : UICollectionView = { let layout = UICollectionViewFlowLayout() let collectionView = UICollectionView( frame: self.view.bounds, collectionViewLayout: layout) collectionView.backgroundColor = UIColor.hex(string: "#eeeeee", alpha: 1) collectionView.delegate = self collectionView.dataSource = self collectionView.register(CollectionViewCell.self, forCellWithReuseIdentifier: "cellId") self.cellidentifier = "cellId1" return collectionView }() lazy var collectionView2 : UICollectionView = { let layout = UICollectionViewFlowLayout() let collectionView = UICollectionView( frame: self.view.bounds, collectionViewLayout: layout) collectionView.backgroundColor = UIColor.hex(string: "#eeeeee", alpha: 1) collectionView.delegate = self collectionView.dataSource = self collectionView.register(CollectionViewCell.self, forCellWithReuseIdentifier: "cellId2") self.cellidentifier = "cellId2" return collectionView2 }()次にアイテムの個数や表示内容、タッチ時の処理collectionViewの設定を設定します。
// 表示するアイテムの数 func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { if collectionView == self.collectionView2 { //collectionView2で設定したいアイテム数 self.array_count = array2.count } else{ //collectionView1で設定したいアイテム数 self.array_count = array1.count } return self.array_count } // アイテムの大きさ func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let size = self.view.frame.width / 3.5 return CGSize(width: size, height: size) } // 上下左右の余白設定 func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { let inset = (self.view.frame.width / 4) / 8 return UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset) } // 余白の最小値を設定 func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { return (self.view.frame.width / 4) / 6 } // アイテムの表示内容(UICollectionViewDataSource が必要) func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { if collectionView == self.collectionView2 { let cellA = collectionView.dequeueReusableCell(withReuseIdentifier: "cellId2", for: indexPath) as! CollectionViewCell cellA.backgroundColor = UIColor.lightGray let item = array1[indexPath.row] cellA.setUpContents(item) //他のファイルで設定したカスタムセルを呼び出す return cellA } else { let cellB = collectionView.dequeueReusableCell(withReuseIdentifier: "cellId1", for: indexPath) as! CollectionViewCell cellB.backgroundColor = UIColor.lightGray let item = array2[indexPath.row] cellB.setUpContents(item) //他のファイルで設定したカスタムセルを呼び出す return cellB } } // タッチ時の処理 func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if collectionView == self.collectionView2 { //collectionView2で設定したいタッチ処理 print("collectionView2 is tapped!") } else{ //collectionView1で設定したいタッチ処理 print("collectionView1 is tapped!") } }関数内の処理をif文で分けるのがポイント
参考記事:How can I add multiple collection views in a UIViewController in Swift?
- 投稿日:2021-03-01T00:10:37+09:00
UIKit だけでタブブラウザを作ってオープンソースにした話
2020年末、こよなく愛用していた Smooz という iOS 向けのタブブラウザが突然使えなくなってしまいました。で、代わりのブラウザをいろいろ試してみたものの、Smooz みたいに手に馴染む物が見つからず... ならばと思い、試しに作ってみたところ、意外にも標準的な UI の組み合わせで作れてしまったので、一通り実装してApp Store でリリースしてみました。
また、日頃から Qiita やインターネットでいろいろな方の記事を参考にさせてもらっているので、何か還元できないかと思い、オープンソースにしてみました。独学なのであまり自身はないですが、参考にしていただければ幸いです。
ということで、このアプリをどう作ったかを解説します。
コンセプト
安心して使えるブラウザ
Smooz では個人情報の扱いが問題となったので、一切情報を収集、送信しないブラウザを目指しました1。
- 3rd パーティーライブラリ不使用
- 外部 API 不使用
- アナリティクス不使用
- 広告不使用
- WKWebKit 経由でコンテンツを操作しない
その結果、ケアすることも少なくなり、素早く実装できた気がします。
OS が提供するライブラリだけで作る
3rd パーティーのライブラリを使わないので、UI は UIKit だけで実装。データベースは Core Data を使い、データバインディングに Combine を使用しました。なので、もちろん CocoaPods / Carthage / SPM などは不要で、ビルドもめちゃくちゃ速い!
オープンソース
今回、オープンソースにしてみました。「安心して使えるブラウザ」にするためには、コードの中身も見てもらったほうがいい2 かなというのが元々のアイデアでしたが、自分のコードを参考にしてもらったり、逆に指摘してもらって自分ももっと学べることにメリットがあると思いました。なので、プルリク Welcome です。
画面構成と UIKit の使い方
メイン画面
UINavigationController -> UIViewController
の構成にして、UIViewController
の View にUIPageViewController
とUICollectionView
を配置しています。コンテンツ部分(
UIPageViewController
)
UIPageViewController
は、ビューコントローラをページとして、複数のページを管理できるコントローラです。Web コンテンツを表示する場合には
WKWebView
を表示し、検索ビュー(虫眼鏡を押した時に表示されるビュー)では、検索履歴リストを表示するためにUICollectionView
を表示しています。
Smooz では、左右にスワイプしてタブを切り替えることができ、これがとても便利でした。UIPageViewController
は標準でスワイプでのページ切り替えがサポートされていますが、今回は諦めました。というのも、Web ページがカルーセルなどのスワイプさせるコンテンツを含む場合に、操作が競合してしまうためです。これ解決するの相当大変そう。タブ部分(
UICollectionView
)このエリアには、水平方向にタブが並びスクロールができるようになっています。ここには
UICollectionView
を利用しています。ちなみにこのアプリでは、iOS13 から導入されたモダンなUICollectionView
を使っています。
NSDiffableDataSourceSectionSnapshot
を使うことで、ダイナミックなタブの追加/挿入/削除をうまく処理してくれたのが便利でした。 コードはこの辺り。コンテクストメニュー
タブを長押しすると、コンテクストメニューが表示されて、タブに対する操作ができます。
これは、UICollectionViewDelegate
のcollectionView(_:contextMenuConfigurationForItemAt:point:)
で簡単に設定できます。コードはこの辺り。検索フィールド部分(
UISearchBar
)
navigationItem.titleView
にUISearchBar
を埋めて、検索フィールドを実現しています。コードはここ。操作ボタン部分(
UIToolbar
)Storyboard で View Controller を選択すると、Attributes inspector で
Bottom Bar
というプルダウンメニューがあり、~ Toolbar
という項目を選択すると、Toolbar が表示されます。そこにUIBarButtonItem
を配置しています。最近のアプリではあまり使われていない気がしますが、Storyboard でお手軽にボタンを配置できます。メニュー(
UIMenu
)iOS14 から、ボタンに対してメニューを追加できるようになりました。今回「閉じる」ボタンにこれを使って、長押しして「すべてを閉じる」を実行できるようにしています。コードはこの辺り。
前述のコンテクストメニューに似ていますが、こちらは
UIBarButtonItem
のmenu
プロパティにUIMenu
を渡して設定します(Human Interface Guidelinesでも、別のものとして定義されています)。ブックマーク画面
ブックマークボタンを押すと、ブックマーク画面がモーダルビューで表示されます。その内側の構成は下記のようになってます。
一覧部分(UICollectionView)
ブックマーク/閲覧履歴/検索履歴一覧には、モダン
UICollectionView
を使っていて、検索ビューと共通化をしています。コードはこちら。検索フィールド部分(
UISearchController
)ナビゲーションバーに、項目を絞り込む検索フィールドと、一覧の種類を切り替える Segmented Controls が表示されていますが、これは
navigationItem.searchController
にUISearchController
を設定しています。これはずいぶん前から提供されている仕組みで、簡単に一覧を絞り込む UI を構築できます。コードはこの辺り。ちなみに、前述のメイン画面の検索フィールドとは違う実装をしています(こちらの方がむしろ標準的な実装)。また、
UISearchController
をインスタンスする際に、検索結果を表示するビューコントローラーをsearchResultsController
に指定できますが、今回は使用していません。操作ボタン部分(
UIToolbar
)こちらは、メイン画面と同様の、標準的な
UIToolbar
の実装となっています。設定画面
検索フィールドの右側のボタンを押すと、設定画面がモーダルビューで表示されます。こちらは極力 Storyboard で実装しています。
設定一覧(
UITableView
)設定一覧は、
UITableView
の Static Cells で実装しています。選択された検索エンジン名を表示したり、Safari ビューでコンテンツを表示したり、バージョン番号を動的に取得して表示する部分は、コードで実装しています。検索エンジン選択(
UITableViewController
)いくつかの項目から1つ選択する一覧画面ですが、モダン
UICollectionView
だと多少 too much な感じなので、UITableViewController
で実装しています。Safari ビュー(
SFSafariViewController
)フィードバックフォーム(Goole フォーム)や、プライバシーポリシー(Web ページ)は Safari View を使ってモーダル表示しています。Safari View は Storyboard では実現できないので、コードで表示しています。
オンボーディング画面(
UIPageViewController
)初回起動時のオンボーディング画面は、ページをめくる構成なので、
UIPageViewController
を利用しています。また、コンテンツ部分は Storyboard で作りました。
各ページはUIViewController
(下段の5つ)となっていて、Storyboard ID をキーにして下記のようにコードに読み込んでいます。pages = (1...5).map { storyboard.instantiateViewController(withIdentifier: "View\($0)") }その他
アイコン
各所でアイコンを使っていますが、これらはすべて SF Symbols を使っています。コードでは下記のように指定します。
UIImage(systemName: "magnifyingglass"),
systemName
に指定する文字列は SF Symbols 2 という macOS アプリで調べると便利です。アプリアイコン
唯一、ビジュアルデザインが必要なのがアプリのアイコンです。シンプルを売りにするアプリなので、アイコンもシンプルにしました(というかこの程度しかデザインできない)。ちなみに、Google Slide を使って作成しました。
データハンドリングまわり
Combine
アプリ全体の状態管理は
Browsers
というシングルトンのクラスで行い、各 View Model がそれを Subscribe してビューに必要な情報に変換して、ビューがそれを Subscribe して表示する、という構成になってます。この状態の監視、データの変換に Combine を利用しています。
Combine は SwiftUI と同時期にリリースされた Apple 提供のライブラリで、以前作った SwiftUI ベースのアプリで習得したのですが、UIKit ベースのアプリでも問題なく使えました。
実際の使い方は、説明できるほどちゃんと理解できていないので、ソースコードを見ていただければと思います。なお、アーキテクチャは MVVM っぽい感じになっていると思いますが、ちゃんと学んだことがないのでこれで良いのか自信がないです。
Core Data
タブの状態やブックマーク、履歴などの保存に Core Data を使いました。今のところ問題はなさそうですが、Core Data もあまり自信を持って使っているわけではないので、コードを整理したり、最適化できる余地があるかと思います。
ちなみに、途中で Core Data を使うことにしたので、こちらの記事を参考にさせていただきました。
[Xcode9] 既存プロジェクトにCoreDataを追加する方法 - Qiita
課題
iPad 対応が適当。
iPad はただ iPhone を拡大しただけになっているので、使いやすさを考慮して UI を最適化したり、
UISplitViewController
を使ってブックマーク等へのアクセス性を向上できればと思っています。参考:UISplitViewController 公式ドキュメントの和訳(iOS14 対応)
スクロールした時に上下のバーを隠す処理が適当。
使用感がいまいちなので、もう少しスムーズかつ適切な動きにしたいと思っています。
タブを削除した時に、タブの挙動がおかしくなる。
おそらく
UICollectionView
の扱い方がおかしいのだと思うけど、タブ(UICollectionViewCell
)が1つずれたり、横スクロールがおかしくなってしまう。うーむ。Basic 認証など、いくつかのブラウザ機能やエラーハンドリングがちゃんと実装できていない。
ブラウザとして必要な機能を実装するのに、この記事を参考にさせていただきました。ただ、まだ Basic 認証の入力ダイアログが実装できていなかったり、エラー処理も適当なので、今後ちゃんと実装していきたいと思います。
WKWebViewで必要十分な機能を持ったアプリ内ブラウザを作る - Qiita
Combine のイベントが余計に発行されている気がする。
UI がちらついたり、閲覧履歴が何度も更新されているっぽいので、一度 Combine の流れを見直して、不要なイベントを捨てる必要がありそう。
非同期処理を使いこなせていなさそう。
基本 UI スレッドで動かしているので、Core Data の処理とかを別スレッドで動かせる気がしますが、この辺りも詳しくないので勉強が必要。
メモリ管理もちゃんと検討・実装できていない。
大量にタブを開いた時などの考慮ができていないので、表示するまで読み込まないなどの対応が必要。また、メモリリークもちゃんと検討できていないので、もっと勉強して対応できればと思っています。
まとめ
iOS も 14 となり、既存の UI のアーキテクチャが使いやすく進化していたり、新しい UI が追加されていたり、Combine のような新しいフレームワークも出てきていて、初期の頃に比べたら、より安定したモダンな実装が可能になってきている気がします。逆にいうと、常に学んでいく必要があって追いつくのが大変ではありますが...
それでも、多くのエンジニアの方が Qiita などで新しい技術を素早くわかりやすく共有してくれているので、こうやってアプリを開発することができています。この場を借りて感謝申し上げます。お返しになるかわかりませんが、今回オープンソースにしたので、実際のアプリでどう新しい技術が使われているかの参考になればいいなと思っています。ぜひ、フィードバックもお待ちしております!
もちろん、WKWebView 内で表示している Web コンテンツが、広告を表示したりアクセス解析することを防ぐことはできないです。あ、Content Blockerを実装すれば良いのか? ↩
公開しているソースコードがそのままビルドされて App Store でリリースされている、ということは証明できないので、厳密には安心を提供できているわけではないです。もちろん、ビルド時に変なコードを入れたりはしてないですが。 ↩