20210301のSwiftに関する記事は24件です。

UIが出すNotificationを複数ウインドウ間で処理する

概要

ドキュメントベースのアプリケーションを作っていてNSScrollViewdocumentVisibleRectを継続的に取得しようとしたら突っ掛かった所があって他のUIに応用ができると思ったので書きます。

解決方法

NSUserInterfaceItemIdentifieruuidを含めた状態でNotificationを受け取る。

やろうとした事

普通にNotificationを使う。

まず、NSScrollViewがスクロールされている時に出されるNSScrollViewNSNotification.Name型のdidEndLiveScrollNotificationを使って

class ViewController: NSViewController {
    //...中略
    override func viewDidLoad() {
        super.viewDidLoad()
        //...
        let scrollView = NSScrollView(frame: NSMakeRect(0, 0, 100, 100))
        scrollView.identifier = NSUserInterfaceItemIdentifier("ScrollView")
        //...
        NotificationCenter.default.addObserver(self, selector: #selector(ScrollViewLiveScroll(_:)), name: NSScrollView.didEndLiveScrollNotification, object: nil)
    }

    @objc private func ScrollViewLiveScroll(_ notification: Notification){
        guard let scrollView = notification.object as? NSScrollView else { return }
        if scrollView.identifier?.rawValue == "ScrollView" {
            print(scrollView.documentVisibleRect)
        }
    }
}

しかし、ドキュメントベースのアプリで、scrollViewidentifierが同じなので、ユーザーが作った他のウインドウでのイベントも受け取ってしまう。

KVO (Key-Value-Observing)を使ってscrollView.documentVisibleRectの変更を監視する。

これもしかし、値が=で代入された時は通知されるが、UI内部で変化するdocumentVisibleRectの変化は受け取ってくれないらしい?ので無理。

KVOで出来る方法を知っている方がいたら教えてください。

NotificationCenterを新たに宣言してNotification

クラス内でNotificationCenterを新たに宣言することによって重複した通知を受け取らないようにする。

しかし、UIがNotificationCenter.defaultNotificationを出すことは変えられないので、受け取る側のNotificationCenterを変えてしまっては受け取れない。

解決した方法

scrollViewidentifierが同じなら変えればいいということで、uuidで一意なIDを生成して、そのクラスがインスタンス化された時毎に違う Identifier を生成すればできる!ということで

class ViewController: NSViewController {
    let uuid = UUID()//ここで
    //...中略
    override func viewDidLoad() {
        super.viewDidLoad()
        //...
        let scrollView = NSScrollView(frame: NSMakeRect(0, 0, 100, 100))
        scrollView.identifier = NSUserInterfaceItemIdentifier("ScrollView\(\(uuid.uuidString))")
        //...
        NotificationCenter.default.addObserver(self, selector: #selector(ScrollViewLiveScroll(_:)), name: NSScrollView.didEndLiveScrollNotification, object: nil)
    }

    @objc private func ScrollViewLiveScroll(_ notification: Notification){
        guard let scrollView = notification.object as? NSScrollView else { return }
        if scrollView.identifier?.rawValue == "ScrollView\(\(uuid.uuidString))" {
            print(scrollView.documentVisibleRect)
        }
    }
}

identifierは"UserInterface"には使えるので、NSScrollView以外にも応用できます

余談ですが、この時のidentifierは、

The slash (/), backslash (\), or colon (:) characters are reserved and must not be used in your custom identifiers. Similarly, Apple reserves all identifiers beginning with an underscore (_) character.

https://developer.apple.com/documentation/appkit/nsuserinterfaceitemidentification/1396829-identifierより

(/),(),(:)は使えず、また、(_)はappleが先頭を予約しているので使えません。

参考

uuidの参考

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

UIが出すNotificationを複数ウインドウ間で処理する : Swift

概要

ドキュメントベースのアプリケーションを作っていてNSScrollViewdocumentVisibleRectを継続的に取得しようとしたら突っ掛かった所があって他のUIに応用ができると思ったので書きます。

解決方法

NSUserInterfaceItemIdentifieruuidを含めた状態でNotificationを受け取る。

やろうとした事

普通にNotificationを使う。

まず、NSScrollViewがスクロールされている時に出されるNSScrollViewNSNotification.Name型のdidLiveScrollNotificationを使って

class ViewController: NSViewController {
    //...中略
    override func viewDidLoad() {
        super.viewDidLoad()
        //...
        let scrollView = NSScrollView(frame: NSMakeRect(0, 0, 100, 100))
        scrollView.identifier = NSUserInterfaceItemIdentifier("ScrollView")
        //...
        NotificationCenter.default.addObserver(self, selector: #selector(ScrollViewLiveScroll(_:)), name: NSScrollView.didLiveScrollNotification, object: nil)
    }

    @objc private func ScrollViewLiveScroll(_ notification: Notification){
        guard let scrollView = notification.object as? NSScrollView else { return }
        if scrollView.identifier?.rawValue == "ScrollView" {
            print(scrollView.documentVisibleRect)
        }
    }
}

しかし、ドキュメントベースのアプリで、scrollViewidentifierが同じなので、ユーザーが作った他のウインドウでのイベントも受け取ってしまう。

KVO (Key-Value-Observing)を使ってscrollView.documentVisibleRectの変更を監視する。

これもしかし、値が=で代入された時は通知されるが、UI内部で変化するdocumentVisibleRectの変化は受け取ってくれないらしい?ので無理。

KVOで出来る方法を知っている方がいたら教えてください。

NotificationCenterを新たに宣言してNotification

クラス内でNotificationCenterを新たに宣言することによって重複した通知を受け取らないようにする。

しかし、UIがNotificationCenter.defaultNotificationを出すことは変えられないので、受け取る側のNotificationCenterを変えてしまっては受け取れない。

解決した方法

scrollViewidentifierが同じなら変えればいいということで、uuidで一意なIDを生成して、そのクラスがインスタンス化された時毎に違う Identifier を生成すればできる!ということで

class ViewController: NSViewController {
    let uuid = UUID()//ここで
    //...中略
    override func viewDidLoad() {
        super.viewDidLoad()
        //...
        let scrollView = NSScrollView(frame: NSMakeRect(0, 0, 100, 100))
        scrollView.identifier = NSUserInterfaceItemIdentifier("ScrollView\(\(uuid.uuidString))")
        //...
        NotificationCenter.default.addObserver(self, selector: #selector(ScrollViewLiveScroll(_:)), name: NSScrollView.didLiveScrollNotification, object: nil)
    }

    @objc private func ScrollViewLiveScroll(_ notification: Notification){
        guard let scrollView = notification.object as? NSScrollView else { return }
        if scrollView.identifier?.rawValue == "ScrollView\(\(uuid.uuidString))" {
            print(scrollView.documentVisibleRect)
        }
    }
}

identifierは"UserInterface"には使えるので、NSScrollView以外にも応用できます

余談ですが、この時のidentifierは、

The slash (/), backslash (\), or colon (:) characters are reserved and must not be used in your custom identifiers. Similarly, Apple reserves all identifiers beginning with an underscore (_) character.

https://developer.apple.com/documentation/appkit/nsuserinterfaceitemidentification/1396829-identifierより

(/),(),(:)は使えず、また、(_)はappleが先頭を予約しているので使えません。

参考

uuidの参考

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

【Swift5】UIScrollView上のUIButton上でスクロールできない

事象

個人開発で、スクロールビュー上にUIButtonを配置して
ドロワーを実装した時、UIButton上でスクロールできない問題に
ぶつかったのでその時の解決策をメモ

原因

まず、UIControlに準拠するUIButton上では、
スクロールイベントが発火しない。

[UIScrollView touchesShouldCancelInContentView:] The default returned value is YES if view is not a UIControl object; otherwise, it returns NO.

これはどういうことかというと、タッチ対象がUIControlオブジェクトの時、
UIButtonの時は、touchesShouldCancelがfalseで返ってくる。
つまり、タップイベントがキャンセルされない。

解決

この問題を解決するには、UIScrollView上のUIButton上では
touchesShouldCancelInContentViewでtrueを返してあげれば良い

カスタムクラスを作成

class CustomScrollView: UIScrollView {

    /// ScrollView上のボタン上でドラッグした時、ドラッグを優先する(tagを1にしておく
    override func touchesShouldCancel(in view: UIView) -> Bool {
        if view.tag == 1 {
            return true
        }
        return super.touchesShouldCancel(in: view)
    }
}

tagを設定しておく

カスタムクラスの

view.tag == 1

を見ると、"tagが1の時"という条件がついている。
storyboard、もしくはコード上で対象のUIButtonオブジェクトのtagを1に設定しておくことで、
この条件式が使える。

終わりに

壁にぶつかっている時はスムーズに開発できずに苦しいけど、
できることをやっている時は成長していない時だと思えば
壁に立ち向かいやすくなるかもしれない。

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

【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で続きを読む

【Swift】ライブラリ "Material Components" の MDCOutlinedTextField を使ったTextField実装

これはなに

マテリアルデザインのUIが簡単に実装できる Material Components を使ってこんな感じのTextFieldを作りたい。

8e206d4c9234c0106c809d47f2c61e79.gif

Material Componentsのインストール方法は省く。

実装してみる

41c5ba946265cc8c685e8ffddb57348a.png

StryboardのTextFieldを選択して MDCOutlinedTextField を設定する。
そしたらControllerにつなぐ。

LoginViewController.swift
class LoginViewController: UIViewController {

    // ユーザ名入力欄
    @IBOutlet weak var userNameTextField: MDCOutlinedTextField!

// 中略

TextFieldのプロパティを設定していく。

LoginViewController.swift
override func viewDidLoad() {
        super.viewDidLoad()

        userIdTextField.label.text = "ユーザーなまえ"
        userIdTextField.placeholder = "なまえをいれなさい"

        // TextFieldが選択されていない状態の枠と文字の色
        userIdTextField.setOutlineColor(.gray, for: .normal)
        userIdTextField.setFloatingLabelColor(.gray, for: .normal)

        // 編集中の枠と文字の色
        userIdTextField.setOutlineColor(.blue, for: .editing)
        userIdTextField.setFloatingLabelColor(.blue, for: .editing)
}

これで上記の動画のようなTextField実装できる。

問題点

――が、ここまではドキュメントを読めば書いてあったんだけど、 userIdTextField.label.text の部分、つまりここでいう『ユーザーなまえ』の部分の色を変える方法がなかなか見つからなかった(userIdTextField.label.textColor ではダメだった)。

解決策

userIdTextField.setNormalLabelColor(.gray, for: .normal)

29aef63b5023ea9509543814b6e11ab7.png

ハー、できてよかった( ;∀;)

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

【Swift5】Timerを変数に保存しないでinvalidateする

結論から言えばそんなに大したことない話なんですけど、意外と使うので備忘録としてアウトプット。

とっても便利なTimer

アプリを作るときには、何かしらの処理でほぼ必ず使うであろうTimer。
Swiftにも使いやすいTimer関数が用意されています。

ViewController.swift
class ViewController: UIViewController {

  var timer: Timer = Timer()
  var count: Int = 0

  override func viewDidLoad() {
        super.viewDidLoad()

        // 1秒毎にup()を実行
        timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(self.up), userInfo: nil, repeats: false)
  }

  @objc func up() {
        // 60秒経ったらタイマーを止める
        if count > 60 {
            timer.invalidate()
        } else {
            count += 1
        }
  }

}

こんな感じで変数にタイマーを保存して、後から.invalidate()してタイマーをストップ&破棄するのが基本的な使い方です。

毎回変数に入れるのだるくない?

ストップウォッチ的な奴は別ですが、上のサンプルみたいに「x秒後に一回処理を実行する」みたいなパターンではすぐにタイマーを破棄することになるのでいちいち変数に入れるのはめんどくさいですよね。
しかもタイマーの数が増えていく場合には変数を追加していかなければならない。

そこでタイマーを変数に入れなくても止める(invalidate()する)ことにしました。

引数に自分自身を入れちゃおう

ViewController.swift
class ViewController: UIViewController {
        // 30秒後に止まるタイマー
        func setTimer() {
            Timer.scheduledTimer(timeInterval: 30, // x秒後
                                 target: self,
                                 selector: #selector(self.stopTimer(_:)), // 引数にTimer自身を指定する
                                 userInfo: nil, 
                                 repeats: false)
            print("Timer started")
        }

        @objc func stopTimer(_ timer: Timer) {
            timer.invalidate()
            print("Timer stopped")
        }
}

このようにselectorで関数を指定するときに引数を自分自身にすることで、変数にTimerのインスタンスを保持しなくてもあとで.invalidate()することができます。

おしまい

Timerはメモリを圧迫するので、不要になったら必ず.invalidate()しよう。

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

【SwiftUI】ListでRowの開閉アニメーション

SwiftUIのListで、Rowタップ時にRowの高さをアニメーションさせつつ変化させます。

環境

  • Swift:5.3.2
  • Xcode:12.4 (12D4e)

コード

ContentView.swift
import SwiftUI

struct ContentView: View {
    @State var selected: UUID?
    @State var scrollTop = false

    var items = (0..<100).map { ItemData(value: $0 * 2 + 10001) }

    var body: some View {
        ScrollViewReader { proxy in
            NavigationView {
                List(items) { item in
                    ItemView(item: Binding.constant(item), parent: Binding.constant(self))
                }
                .listStyle(PlainListStyle())
                .navigationBarTitle(Text("List01"), displayMode: .automatic)
                .navigationBarItems(trailing:
                    Button(scrollTop ? "Top" : "Bottom") {
                        withAnimation {
                            proxy.scrollTo((scrollTop ? items.first : items.last)!.id, anchor: .top)
                            scrollTop = !scrollTop
                        }
                    }
                )
            }
        }
    }
}

struct ItemData: Identifiable {
    var value = 0
    let id = UUID()

    var idString: String {
        get { return String(id.uuidString.prefix(5)) }
    }
}

struct ItemView: View {
    @Binding var item: ItemData
    @Binding var parent: ContentView
    @State var openCell = false

    var body: some View {
        ZStack {
            Color.blue.opacity(0.3)
            VStack {
                Text("Item \(item.idString) => \(item.value)")
                    .frame(maxWidth: .infinity, alignment: .leading)
                Text("--")
                    .multilineTextAlignment(.trailing)
                    .frame(maxWidth: .infinity, alignment: .trailing)
                Spacer()
            }
            .opacity(openCell ? 0 : 1)
            VStack {
                Text((0..<(openCell ? 5 : 1)).map { "Detail \($0)" }.joined(separator: "\n"))
                    .multilineTextAlignment(.trailing)
                    .frame(maxWidth: .infinity, alignment: .trailing)
            }
            .opacity(openCell ? 1 : 0)
        }
        .fixedSize(horizontal: false, vertical: true)
        .modifier(AnimatingCellHeight(height: openCell ? 120 : 60))
        .animation(.linear)
        .contentShape(Rectangle())
        .onTapGesture {
            openCell.toggle()
        }
    }

    init(item: Binding<ItemData>, parent: Binding<ContentView>) {
        _item = item
        _parent = parent
        print("init: \(item.wrappedValue.idString)")
    }
}

struct AnimatingCellHeight: AnimatableModifier {
    var height: CGFloat = 0

    var animatableData: CGFloat {
        get { height }
        set { height = newValue }
    }

    func body(content: Content) -> some View {
        content.frame(height: height)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

リポジトリ

問題点

  • Rowの高さが固定値で入っている(GeometryReaderを使うとうまくいかなかった)
  • アニメーションがぎこちない(modifierか何かがおかしい?)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UITableView とTextField備忘録 Outlet cannot be connected to repeating content

StoryboardからUITableVIewのCellの中でTextFiledなどを配置して
ViewControllerへ連携したら、ビルドする時にエラーになりました:
Outlet cannot be connected to repeating content

Cellは繰り返し使用されるオブジェクトであるため、直接Outlet接続できないらしい、
解決方法としてサブクラスで実装、つまりXibファイルで実装になります。
こちらの記事を参考しました。

TableViewとTexFieldの組み合わせ実装の備忘録としてメモしておきます。

1.セールの中身を一括で宣言

enum Section: CaseIterable {
    case a
    case t

    var text:[String]? {
        switch self {
        case .a:
            return ["A","a","あ"]
        default:
            return nil
        }
    }

    var identifiers:[String]{
        switch  self {
        case .a:
            return ["acell","acell","acell"]
        case .t:
            return ["tcell","tcell","tcell"]
        }
    }

    var header:String{
        switch self {
        case .a:
            return "aHeader"
        case .t:
            return "tHeader"
        }
    }

    var indicator:Bool{
        switch self {
        case .a:
            return true
        case .t:
            return false
        }
    }
}

2.ViewControllerでデータを反映

extension ViewController: UITableViewDataSource {

    func numberOfSections(in tableView: UITableView) -> Int {
        return Section.allCases.count
    }
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return Section.allCases[section].identifiers.count
    }

    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return Section.allCases[section].header
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let section = Section.allCases[indexPath.section]
        let cell = tableView.dequeueReusableCell(withIdentifier: section.identifiers[indexPath.row], for: indexPath)
        return cell
    }
}

extension ViewController: UITableViewDelegate{

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 45.0
    }

    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        let section = Section.allCases[indexPath.section]
        if section.indicator {
            cell.accessoryType = .disclosureIndicator
        }

        if section.identifiers[indexPath.row] == "acell"{
            cell.textLabel?.text = section.text![indexPath.row]
        }
    }
}

3.二種類のCellを作る
1)Cellの中にLableを配置してTextを表示する”acell”
2)Cellの中にTextFieldを配置して入力できるようにする”tcell”

4.Xibファイルで作ったCellを登録

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!

    override func viewDidLoad() {
        super.viewDidLoad()
        let nib = UINib.init(nibName: "TextFieldCell", bundle: nil)
        tableView.register(nib, forCellReuseIdentifier: "tcell")
    }
}

5.StoryBoardでDataSourceとDelegateの連携も忘れずに
この段階ではこのようなTableViewになっています。
Simulator Screen Shot - iPhone 8 - 2021-03-01 at 14.20.09.png

6.Keyboardを閉じる処理
TextFieldで入力中、TableViewをスクロールすると、Keyboardを閉じる処理

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
    self.view.endEditing(true)
}

7.入力箇所が常に画面の真ん中に表示するように処理
例えば、画面の下側のCellのTextFieldをタップしたら、TableView全体を上へスクロール、
入力中のCellが常に真ん中に表示するようには、2ステップです。
1)UITextFieldDelegateのBeginEditingが入力始めると(Keyboardが表示すると)呼ばれるDelegateで
 TableView.ScrollToRowを実装
2)Keyboardの高さ分を画面全体を上へ持ち上げる実装

1)はUITextFieldのXibクラスでDelegateを作り、使用します。delegate = selfを忘れずに

protocol TestTextFieldDelegate: class {
    func beginEditing(_ cell:TextFieldCell)
}

extension TextFieldCell: UITextFieldDelegate { 
    func textFieldDidBeginEditing(_ textField: UITextField) {
        self.textField!.becomeFirstResponder()
        self.delegate?.beginEditing(self)
    }
}
extension ViewController: TestTextFieldDelegate {
    func beginEditing(_ cell: TextFieldCell) {
        let index = tableView.indexPath(for: cell)
        tableView.scrollToRow(at: index!, at: .middle, animated: true)
    }
}

2)はVieControllerの方で実装

class ViewController: UIViewController {

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var tableBottomLayout: NSLayoutConstraint!

    override func viewDidLoad() {
        super.viewDidLoad()
        let nib = UINib.init(nibName: "TextFieldCell", bundle: nil)
        tableView.register(nib, forCellReuseIdentifier: "tcell")
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        regsiterKeyboardNotification()
    }

    private func regsiterKeyboardNotification(){
        let notification = NotificationCenter.default
        notification.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
        notification.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil)
    }

    @objc func keyboardWillShow(_ notification:Notification?){
        guard let userInfo = notification?.userInfo,
              let keyboradInfo = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue,
              let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else{
            return
        }
        let keyboardHieght = keyboradInfo.cgRectValue.size.height
        self.tableBottomLayout.constant = keyboardHieght

        UIView.animate(withDuration: duration){
            self.view.layoutIfNeeded()
        }
    }

    @objc func keyboardWillHide(_ notification:Notification?){
        guard let userInfo = notification?.userInfo,
              let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else{
            return
        }

        self.tableBottomLayout.constant = 0

        UIView.animate(withDuration: duration){
            self.view.layoutIfNeeded()
        }
    }
}

tableBottomLayoutですが、SotryBoardはこのようになっています。
FirstItemとSecondItemが逆だとうまくいかないみたい。
スクリーンショット 2021-03-01 15.15.03.png

8.入力内容制限
1)xx文字以上入力できない
StoryBoardからTextFieldのAction:EditingChangedをXibファイルへ接続して、実装します。

@IBAction func textChanged(_ sender: UITextField) {
    checkMaxLength()
}

private func checkMaxLength(){
    var text = textField.text
    guard text != nil else{
        return
    }
    if text!.count > 5 {
        text = String(text!.prefix(5))
    }
    if self.textField.text != text{
        self.textField.text = text
    }
}

2)特殊の文字を入れないようにする
例えば、英数字記号以外を入力できないようにするには以下で実装します

extension String {

    var ns: NSString{
        return self as NSString
    }

    func deleteMatched(parttern:String?)-> String{
        guard let parttern = parttern else{
            return self
        }

        guard let regex = try? NSRegularExpression(pattern: parttern, options:
                                                    NSRegularExpression.Options.caseInsensitive) else { return self }
        let range = NSRange(location: 0, length: self.ns.length)
        let modStirng = regex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: "")
        return modStirng
    }
}

入力できない文字列を宣言

let NONAlphanumericAndSymbols = "[^a-zA-Z0-9!#&'()*+,/:;=?@\\[\\]\\-\\._~]+"
private func checkMaxLength(){
    var text = textField.text
    guard text != nil else{
        return
    }

 ///こちらを追加
    text = text?.deleteMatched(parttern: self.NONAlphanumericAndSymbols)

    if text!.count > 5 {
        text = String(text!.prefix(5))
    }
    if self.textField.text != text{
        self.textField.text = text
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Could not instantiate class named WKWebView because no class named WKWebView was found; the class needs to be defined in source code or linked in from a library (ensure the class is part of the correct target)"

フレームワークにWebKit.frameworkを追加すればOKです。

参考

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む

enumの使い方について簡単にまとめる

スキル不足だった僕はそれぞれの場所で宣言すればいいじゃんなんて思っていたわけなのだが、企業とかで使われるアプリを案件を通して行ったときにいろんな場所で宣言しまくるのはあまりよくないななんて思った。

確かに楽なんだけど、あれ、、、、○○○○って何番だっけみたいな感じであまり使わないやつとかだとマジでわからなくなる

そんな時にenumの使い方と実際の使われ方を見てこっちの方が間違いなく便利なんて思ったので自分が見返せるようにするためにも残しておく

例えばenumの使い方はこんな感じ

商品の状態を数字で管理している場合

0が販売

1が在庫

2がなんちゃらみたいな

そんな感じの場合

enum 商品: Int {
case 販売 = 0
case 在庫 = 1
case なんちゃら = 2
}

こんな感じで配置しておけば関数とか作成した場合でも販売を呼び出したり在庫を呼び出したりすればいいだけなので、ある程度スキルが身に付けばこっちの方が楽だななんて思えるようになる

これからプログラミングを始めるとかそういう方は「なんだこれ、、、」みたいな感じになるだろうけど、理解して実際に現場で経験すればこっちの方が楽かもみたいになると思います。

enumの使い方は未経験とか個人で小さくアプリを作成している方からするとあまり使う機会は多くないかもですが、割と便利なので頑張って覚えてください。

当時の僕に向けたメッセージでした。

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

[iOS] Storyboard Referenceを活用しStoryboardを分割する

はじめに

storyboardを使用したiOSアプリ開発において、1つのStoryboardに対したくさんのViewControllerが詰め込まれているのは良くないということなので、Storyboardを分割する方法を記載します。

1つのStoryboardにViewControllerを複数突っ込んだ良くない例 StoryboardReferenceを活用し、Storyboardを適切に分割している例

概要

環境

Xcode : Version 12.4

内容

ViewControllerを用意し、それに対応するStoryboardファイルを作成する。
(1画面につき1つのViewControllerである前提です)

実装手順

1) Storyboardファイルを作成する

main.storyboardと同ディレクトリにてcommond + Nで新規ファイルを作成します。

ファイルの種類をStoryboardと選択し、決定します。

その後にファイル名を入力するのですが、今回は"second"とします。
(この時、初期値がStoryboardと大文字Sで入力されているのですが、これを残した状態でsecond.Storyboardなどとしてしまうと、拡張子エラーでファイルが開けなくなるので注意してください)

2) ViewControllerファイルを作成する

次に、新しく作成された上記のsecond.storyboardに対応するViewControllerファイルを作ります。

先ほどと同じ要領で作成していきます。
Cocoa Touch Classを選択し、ファイル名はsecondViewControllerとしました。

5スクリーンショット.png

このように2つのファイルが出来ていればOKです。

3) Storyboard Referenceを設置する

ではいよいよStoryboard Referenceを設置していきます。
main.storyboardのLibraryからObjectを検索します。refと入れれば出てくるので空いているところにドラッグ&ドロップしていきます。

設置できたらStoryboard ReferenceのInspectorsで、対応するStoryboardを選びます。今回は先ほど作成したsecondとなります。

4) segueの設定を行う

画面遷移のためにsegueの設定をします。
ViewControllerからStoryboard Referenceにsegueを繋ぎます。

segueを繋いだらsegue Identifierも設定しておきましょう。今回はgoSecondとします。

segueについての解説は本記事では省略しますが、以下の記事がとても分かりやすく参考にさせて頂きました。ありがとうございます。
- 【Swift5/Xcode】画面遷移のチートシート。Segueを画面遷移とSegueを使わない画面遷移を徹底解説

5) 作成したstoryboardにViewControllerを設置

1)で作成した何もない状態のsecond.storyboardにViewControllerを設置します。

設置後はViewControllerのCustom Classの設定、Is Initial View Controllerのチェックをします。

6) 画面遷移を試す

Storyboardを切り離す作業・設定は完了したので、下記2点を追記してちゃんと動作するかどうかを画面遷移させてみて確認します。

  • main.storyboardにButtonを設置
  • ViewController.swiftに画面遷移のメソッドを記述
  • (遷移先のViewに色をつけて分かりやすくする)
// ViewController.swift
@IBAction func goSecondButtonTapped(_ sender: UIButton) {
    performSegue(withIdentifier: "goSecond", sender: nil)
}

これで無事に動作することが確認できました!

後語り

以上がStoryboard Reference活用による、Storyboardを分割して1つのStoryboardに対して1つのViewControllerという配置をする方法となります。
このようにStoryboardを分割して管理することで、コンフリクトも起こりにくくなります。
私自身もしっかりと理解を深め活用していきたいと思います。

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

【Xcode/Swift豆知識 #1】 「Color Literal」で直感的にカラー指定

はじめに

Xcode標準搭載のカラーパレット「Color Literal」を使うことで、直感的にUIColorを指定できます。
本記事は、

  • コードで色を指定するのが面倒と感じる人
  • NavigationBarやTabBarなどの色を直感的に変更したい人

これらに当てはまる人におすすめです!

開発環境

  • Swift5
  • Xcode 12.4

実際のコード

「color...」と打つと、UIColorの候補に「Color Literal」が出てきます。

Color Literal 1.png

カラーパレットらしきものに自動で変換されます!
Color Literal 2.png

よく使われる色から選択することも・・・
Color Literal 3.png

RGBから色を決めたり、Opacity(透明度)なんかもいじれちゃいます!便利!
Color Literal 4.png

最後に

今日はXcode / Swift豆知識その1として、「Color Literal」をご紹介しました。

NavigationBarやTabBarの色を変更したいときは特に便利ですので
ぜひ使ってみてくださいね!

参考記事:iPhoneアプリのヘッダー(ナビゲーションバー)の色を変更する

ここまで読んでいただいて、ありがとうございました。

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

【Swift】UIPickerViewについて コードで表示/複数列

はじめに

私がSwiftを勉強し始めて一番最初に苦戦したのがPickerViewでした。なので個人でのQiitaの最初の投稿もPickerViewについてです!
私だけでなく、よくわかんないって人が意外と多いと感じたので簡単にまとめてみました。
参考までにどうぞ。

storyboardの作成

今回は複数の列を選択できるPickerViewを作成するので、わかりやすくするためにTextFieldはstoryboardで設置した。
スクリーンショット 2021-03-01 1.59.35.png

手順

①デリゲートメソッドを追加
②変数宣言
③PickerViewの設定
④toolbarの設定

①デリゲートメソッドを追加

UIPickerViewDelegate, UIPickerViewDataSource

これらを追加します。
(最終的なコードは最後にまとめて表示します。)

②変数宣言

ViewController.swift
    @IBOutlet weak var year:UITextField!
    @IBOutlet weak var month:UITextField!
    @IBOutlet weak var day:UITextField!
    var PickerView = UIPickerView()

storyboardに配置したTextFieldと今回使うPickerViewの宣言を行います。

ViewController.swift
    var yearArray:[String] = ["2021","2022","2023","2024","2025","2026","2027","2028","2029","2030"]
    var monthArray:[String] = ["1","2","3","4","5","6","7","8","9","10","11","12"]
    var dayArray:[String] = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24","25","26","27","28","29","30","31"]

また今回表示させる配列も宣言しておきます。

ViewController.swift
override func viewDidLoad() {
        super.viewDidLoad()
        PickerView.delegate = self
        PickerView.dataSource = self
}

またViewDidLoadの中に、この画面でPickerViewのデリゲートメソッドを使用することを言っておきます。
ここは忘れやすいので注意!

③PickerViewの設置

さて準備も終わったところなのでいよいよ本題のPickerViewの設定です。

ViewController.swift
override func viewDidLoad() {
        super.viewDidLoad()
        //さっき書き込んだところ
        PickerView.delegate = self
        PickerView.dataSource = self
     //追記
        year.inputView = PickerView
        month.inputView = PickerView
        day.inputView = PickerView

}

まずViewDidLoadの中で、先ほど宣言したTextFieldにPickerViewを当てはめます。
これを行わないとtextFieldを選択してもPickerViewが下から出てきません。

ここからはPickerViewの中身について具体的に設定していきます

列の設定

ViewController.swift
    func numberOfComponents(in pickerView: UIPickerView) -> Int {
        return 3
    }

まず列の個数を選択します。西暦、年、日の3つを同時に記入したいので、列の個数は3です。
3を返すように設定しましょう。

行の設定

ViewController.swift
    func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
        switch component {
        case 0:
            return yearArray.count
        case 1:
            return monthArray.count
        case 2:
            return dayArray.count
        default:
            return 0
        }
    }

それぞれのTextFieldで選択したい値の数が違うのでswich文を用いて条件分岐を行います。
デリゲートメソッドが記入されている状態で"numb"ぐらいまで打つと選択肢として出てきますよ!!

表示名の設定

ViewController.swift
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
        switch component {
        case 0:
            return yearArray[row]
        case 1:
            return monthArray[row]
        case 2:
            return dayArray[row]
        default:
            return "error"
        }
    }

表示名って何?って思う人もいるかもしれませんが、簡単にいうとPickerViewの内容を記入するものになります。
このコードがないとPickerViewの内容が"?"となって選択されているものがどれかわからないので、必ず書きましょう。

以上でPickerViewの設定は終了となります。

次はPickerViewで選択したものをTextFieldに表示させます

ViewController.swift
    func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
        switch component {
        case 0:
            year.text = yearArray[row]
        case 1:
            month.text = monthArray[row]
        case 2:
            day.text = dayArray[row]
        default:
            break
        }
    }

簡単ですね。同じようにして表示させるだけです。

ここまででPickerViewの実装は完了しました!

こんな感じになってれば完璧です。
Simulator Screen Shot - iPhone 11 - 2021-03-01 at 03.01.46.png

次はtoolbarを実装してちょっとだけおしゃれにしていきましょう

そもそもtoolbarって?

これから説明するコードを実装すると以下のようになります。
Simulator Screen Shot - iPhone 11 - 2021-03-01 at 03.12.44.png

そうなんです。上にちょっぴり生えてきたものありますよね?それが今回表示させたいtoolbarです。

今から解説していきますね!

ViewController.swift
override func viewDidLoad() {
        super.viewDidLoad()
        //さっき書き込んだところ
        PickerView.delegate = self
        PickerView.dataSource = self
     //さっき書き込んだところ
        year.inputView = PickerView
        month.inputView = PickerView
        day.inputView = PickerView
    //追記
     let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: view.frame.size.width, height: 50))
        let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil)
        let done = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(ViewController.donePressed))
        let cancel= UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(ViewController.cancelPressed))
        toolbar.setItems([cancel , space, done ], animated: true)

        year.inputAccessoryView = toolbar
        month.inputAccessoryView = toolbar
        day.inputAccessoryView = toolbar

}

最初にtoolbarを作らないといけませんね。なのでViewDidLoadの中で上の追記のようにします。
まず、toolbarをどのようなものするかを決め、そしてそこに表示させるアイテム(ここではcancelとdoneとspace)を設定します。次ににtoolbarの中に順番に表示させて見やすいようになるまで工夫します。
そして最後に、toolbarを表示させるコードを書いたら完了です。

ここで気づいた方もいるかもしれませんが、
「"ViewController.donePressed" , "ViewController.cancelPressed"」 ってなんだよ!って思いませんでしたか?
ごめんなさい...順番が難しくて...今から解説していきます!

まず何かと簡単にいうと、cancelボタンとdoneボタンが押された時にどうするかというものですね!
それを決めないとtoolbarを置いた意味がありません。

今回はcancelボタンとdoneボタン両方ともPickerViewが画面から消えるということにします!
簡略化してすいません?‍♂️
コードは以下の通りになります。

ViewController.swift
    @objc func donePressed() {
           view.endEditing(true)
       }

    @objc func cancelPressed() {
           view.endEditing(true)
       }

これをclass内のViewDidLoadの外に記入してください。

はい!これで以上になります!

最終的には以下のようになります。

import UIKit

class ViewController: UIViewController, UIPickerViewDelegate, UIPickerViewDataSource {

    @IBOutlet weak var year:UITextField!
    @IBOutlet weak var month:UITextField!
    @IBOutlet weak var day:UITextField!
    var PickerView = UIPickerView()

    var yearArray:[String] = ["2021","2022","2023","2024","2025","2026","2027","2028","2029","2030"]
    var monthArray:[String] = ["1","2","3","4","5","6","7","8","9","10","11","12"]
    var dayArray:[String] = ["1","2","3","4","5","6","7","8","9","10","11","12","13","14","15","16","17","18","19","20","21","22","23","24","25","26","27","28","29","30","31"]

    override func viewDidLoad() {
        super.viewDidLoad()

        PickerView.delegate = self
        PickerView.dataSource = self

        year.inputView = PickerView
        month.inputView = PickerView
        day.inputView = PickerView

        let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: view.frame.size.width, height:50))
        let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil)
        let done = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(ViewController.donePressed))
        let cancel = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(ViewController.cancelPressed))
        toolbar.setItems([cancel , space, done ], animated: true)

        year.inputAccessoryView = toolbar
        month.inputAccessoryView = toolbar
        day.inputAccessoryView = toolbar

    }

    func numberOfComponents(in pickerView: UIPickerView) -> Int {
        return 3
    }

    func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
        switch component {
        case 0:
            return yearArray.count
        case 1:
            return monthArray.count
        case 2:
            return dayArray.count
        default:
            return 0
        }
    }

    func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
        switch component {
        case 0:
            return yearArray[row]
        case 1:
            return monthArray[row]
        case 2:
            return dayArray[row]
        default:
            return "error"
        }
    }

    func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
        switch component {
        case 0:
            year.text = yearArray[row]
        case 1:
            month.text = monthArray[row]
        case 2:
            day.text = dayArray[row]
        default:
            break
        }
    }


    @objc func donePressed() {
           view.endEditing(true)
       }

    @objc func cancelPressed() {
           view.endEditing(true)
       }

}

さいごに

今回はswich文を用いて条件分岐しましたが、列が1つの場合などはこのように分岐しなくて大丈夫です。
また、もう少し綺麗な書き方がある!とか少し違う!とかツッコミたくなるかもしれませんがご放念ください。

  • このエントリーをはてなブックマークに追加
  • 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で続きを読む