20210301のiOSに関する記事は12件です。

【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.swift
enum 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にこちらをコピペしていただくだけでも動くと思います。
読んでいただいた方のお役に立てれば幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[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)
  }
}

以上です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【SwiftUI】alertメソッドでアラートを出す

先日リリースした私のアプリに使用した技術をひとつずつ解説しています。
私のアプリはこちら。

alertとは

下のようなアラートを出すメソッド。iPhoneを使っているといろんな場面で見かけるかと思います。

スクリーンショット 2021-03-01 15.37.50.png

実際の動作

このように、ボタンを押すとアラートが出るサンプルアプリを作りました。こちらについて解説します。

基本的な書き方

まず、alertメソッドの基本的な書き方を説明します。

alertメソッド
.alert(isPresented: ブール値, content: { Alert(アラートの内容) })

引数のisPresentedがtrueになったときにアラートが出現します。
アラートの内容はcontentに書き、Alert構造体を使います。

サンプルアプリのコードについて解説します。

ソースコード

ContentView.swift
import 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に書かれているアラートが出現します。

このように書くことで出現するアラートがこちらです。

スクリーンショット 2021-03-01 15.37.50.png

こうして見ると、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さえあればアラートとして機能します

実際に出現するアラートがこちら。

スクリーンショット 2021-03-01 17.18.43.png

Alert構造体にはタイトルしか書いていませんが、自動的にOKボタンをつけてくれます。このボタンを押すとisPresentedがfalseになり、アラートが消えます。(いいね!ボタンの方も同様の仕組みでアラートが消えています)

アラートのボタンについて

先ほど述べたように、Alert構造体はtitleがあれば機能し、アラートを消すボタンを自動的につけてくれますが、どのようなボタンにするかを指定することができます。

アラートのボタンはひとつ、もしくはふたつ付けることができます。それぞれ使用する引数が違うので、別々に解説したいと思います。

ボタンがひとつの場合

先ほどのよくないね!アラートを次のように書きかえます。

                .alert(isPresented: $noGoodAlert, content: {
                    Alert(title: Text("そんなこと言わないで"),
                          dismissButton: .default(Text("了解です")))
                })

dismissButtonという引数を追加しました。これによりボタンのテキストが変わります。

スクリーンショット 2021-03-01 17.45.41.png

「了解です」ボタンを押すとアラートが消えてくれます。このボタンに機能を追加したい場合、.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というメソッドが使われています。実はこれらがボタンの文字の色を決めています。

スクリーンショット 2021-03-01 15.37.50.png

「はい」ボタンの方はdefault、つまりデフォルトの色の青になります。destructiveとは「破壊的な」という意味があり、これを使った「いいえ」ボタンは文字が赤色になっています。これらふたつを使い分けることで、ユーザーの押し間違いを減らす効果が期待できます。ぜひ使い分けましょう。

まとめ

alertメソッドはおそらく、iPhoneを触っていれば見ない日はないほど頻繁に見る機能かと思います。使うシーンもさまざまなので、ぜひこの機能を応用してアプリに取り入れてみてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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
    }
  }
}



  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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が田中と書かれたすべてのドキュメントが削除されます

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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文

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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は自分で条件に合わせて設定しましょう

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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などがあります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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)
            }
以下略

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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)
        }
    }

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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 に UIPageViewControllerUICollectionView を配置しています。

コンテンツ部分(UIPageViewController

UIPageViewController は、ビューコントローラをページとして、複数のページを管理できるコントローラです。

Web コンテンツを表示する場合には WKWebView を表示し、検索ビュー(虫眼鏡を押した時に表示されるビュー)では、検索履歴リストを表示するために UICollectionView を表示しています。
webcontentsandsearchview.png
Smooz では、左右にスワイプしてタブを切り替えることができ、これがとても便利でした。UIPageViewController は標準でスワイプでのページ切り替えがサポートされていますが、今回は諦めました。というのも、Web ページがカルーセルなどのスワイプさせるコンテンツを含む場合に、操作が競合してしまうためです。これ解決するの相当大変そう。

タブ部分(UICollectionView

このエリアには、水平方向にタブが並びスクロールができるようになっています。ここには UICollectionView を利用しています。ちなみにこのアプリでは、iOS13 から導入されたモダンな UICollectionView を使っています。

NSDiffableDataSourceSectionSnapshot を使うことで、ダイナミックなタブの追加/挿入/削除をうまく処理してくれたのが便利でした。 コードはこの辺り

コンテクストメニュー

タブを長押しすると、コンテクストメニューが表示されて、タブに対する操作ができます。
contextmenu.png
これは、UICollectionViewDelegatecollectionView(_:contextMenuConfigurationForItemAt:point:) で簡単に設定できます。コードはこの辺り

検索フィールド部分(UISearchBar

navigationItem.titleViewUISearchBar を埋めて、検索フィールドを実現しています。コードはここ

操作ボタン部分(UIToolbar

Storyboard で View Controller を選択すると、Attributes inspector で Bottom Bar というプルダウンメニューがあり、~ Toolbar という項目を選択すると、Toolbar が表示されます。そこに UIBarButtonItem を配置しています。最近のアプリではあまり使われていない気がしますが、Storyboard でお手軽にボタンを配置できます。

メニュー(UIMenu

iOS14 から、ボタンに対してメニューを追加できるようになりました。今回「閉じる」ボタンにこれを使って、長押しして「すべてを閉じる」を実行できるようにしています。コードはこの辺り
pulldownmenu.png

前述のコンテクストメニューに似ていますが、こちらは UIBarButtonItemmenu プロパティに UIMenu を渡して設定します(Human Interface Guidelinesでも、別のものとして定義されています)。

ブックマーク画面

ブックマークボタンを押すと、ブックマーク画面がモーダルビューで表示されます。その内側の構成は下記のようになってます。

bookmark.png

一覧部分(UICollectionView)

ブックマーク/閲覧履歴/検索履歴一覧には、モダン UICollectionView を使っていて、検索ビューと共通化をしています。コードはこちら

検索フィールド部分(UISearchController

ナビゲーションバーに、項目を絞り込む検索フィールドと、一覧の種類を切り替える Segmented Controls が表示されていますが、これは navigationItem.searchControllerUISearchController を設定しています。これはずいぶん前から提供されている仕組みで、簡単に一覧を絞り込む UI を構築できます。コードはこの辺り

ちなみに、前述のメイン画面の検索フィールドとは違う実装をしています(こちらの方がむしろ標準的な実装)。また、UISearchController をインスタンスする際に、検索結果を表示するビューコントローラーを searchResultsController に指定できますが、今回は使用していません。

操作ボタン部分(UIToolbar

こちらは、メイン画面と同様の、標準的な UIToolbar の実装となっています。

設定画面

検索フィールドの右側のボタンを押すと、設定画面がモーダルビューで表示されます。こちらは極力 Storyboard で実装しています。
settings.png

設定一覧(UITableView

設定一覧は、UITableView の Static Cells で実装しています。選択された検索エンジン名を表示したり、Safari ビューでコンテンツを表示したり、バージョン番号を動的に取得して表示する部分は、コードで実装しています。

検索エンジン選択(UITableViewController

いくつかの項目から1つ選択する一覧画面ですが、モダン UICollectionView だと多少 too much な感じなので、UITableViewController で実装しています。

Safari ビュー(SFSafariViewController

フィードバックフォーム(Goole フォーム)や、プライバシーポリシー(Web ページ)は Safari View を使ってモーダル表示しています。Safari View は Storyboard では実現できないので、コードで表示しています

オンボーディング画面(UIPageViewController

初回起動時のオンボーディング画面は、ページをめくる構成なので、UIPageViewController を利用しています。また、コンテンツ部分は Storyboard で作りました。

スクリーンショット 2021-02-28 21.22.30.png
各ページは 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 して表示する、という構成になってます。

architecture.png

この状態の監視、データの変換に 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 などで新しい技術を素早くわかりやすく共有してくれているので、こうやってアプリを開発することができています。この場を借りて感謝申し上げます。お返しになるかわかりませんが、今回オープンソースにしたので、実際のアプリでどう新しい技術が使われているかの参考になればいいなと思っています。ぜひ、フィードバックもお待ちしております!


  1. もちろん、WKWebView 内で表示している Web コンテンツが、広告を表示したりアクセス解析することを防ぐことはできないです。あ、Content Blockerを実装すれば良いのか? 

  2. 公開しているソースコードがそのままビルドされて App Store でリリースされている、ということは証明できないので、厳密には安心を提供できているわけではないです。もちろん、ビルド時に変なコードを入れたりはしてないですが。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む