Swift で 画像ビューワを実装する その2 - ダブルタップで拡大縮小

検証環境:
Xcode 11.1
Swift 5.1

前回の続き。
今回は画像をダブルタップしたときに拡大・縮小するように実装を追加する。
ScrollView のタップ箇所をズームさせる方法は Apple のプログラミングガイドを参考に実装する。

developer.apple.com

まず、画像が乗っている ScrollView に対してダブルタップのジェスチャを追加する。

// ScrollView にダブルタップのジェスチャを追加
let gesture = UITapGestureRecognizer(target: self, action: #selector(scrollViewDoubleTapped(_:)))
gesture.numberOfTapsRequired = 2
scrollView.addGestureRecognizer(gesture)

次にタブルタップ時に呼ばれるアクション部分を実装する。
ScrollView の zoomScale が最小値(1倍)だった場合は最大値(4倍)に拡大させ、最小値(1倍)より大きい場合には最小値(1倍)に戻るように縮小する。

private let minZoomScale: CGFloat = 1.0
private let maxZoomScale: CGFloat = 4.0

// ScrollView がダブルタップされた時
@objc private func scrollViewDoubleTapped(_ gesture: UITapGestureRecognizer) {
    guard let scrollView = gesture.view as? UIScrollView else { return }
    if scrollView.zoomScale == minZoomScale {
        // タップされた場所を中心に最大に拡大する
        let location = gesture.location(in: scrollView)
        let rect = zoomRect(for: scrollView, scale: maxZoomScale, center: location)
        scrollView.zoom(to: rect, animated: true)
    } else {
        // 最小に戻す
        scrollView.setZoomScale(minZoomScale, animated: true)
    }
}

private func zoomRect(for scrollView: UIScrollView, scale: CGFloat, center: CGPoint) -> CGRect {
    let size = CGSize(
        width: scrollView.frame.width / scale,
        height: scrollView.frame.height / scale
    )
    let rect = CGRect(
        origin: CGPoint(
            x: center.x - size.width / 2.0,
            y: center.y - size.height / 2.0
        ),
        size: size
    )
    return rect
}

拡大時には UIScrollView の zoom(to:animated:) を使い、縮小時には setZoomScale(_:animated:) を使う。

全コード

class ImageViewerViewController: UIViewController, UIScrollViewDelegate {

    @IBOutlet private weak var scrollView: UIScrollView!

    private let minZoomScale: CGFloat = 1.0
    private let maxZoomScale: CGFloat = 4.0

    private var imageView: UIImageView = {
        let image = UIImage(named: "dog")!
        let imageView = UIImageView(image: image)
        // アスペクト比固定でフィットさせる
        imageView.contentMode = .scaleAspectFit
        return imageView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupScrollView()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        updateImageView()
        updateContentInset()
    }

    private func setupScrollView() {
        scrollView.delegate = self
        scrollView.backgroundColor = .black
        scrollView.minimumZoomScale = minZoomScale
        scrollView.maximumZoomScale = maxZoomScale
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.addSubview(imageView)
        
        // ダブルタップのジェスチャを追加
        let gesture = UITapGestureRecognizer(target: self, action: #selector(scrollViewDoubleTapped(_:)))
        gesture.numberOfTapsRequired = 2
        scrollView.addGestureRecognizer(gesture)
    }
    
    // 画像のアスペクト比を維持したまま ScrollView にぴったり収まるように ImageView のサイズを調整する
    private func updateImageView() {
        guard let size = imageView.image?.size else { return }
        let wRate = scrollView.bounds.width / size.width
        let hRate = scrollView.bounds.height / size.height
        let rate = min(wRate, hRate, 1)
        imageView.frame.size = CGSize(width: size.width * rate, height: size.height * rate)
        // contentSize を画像サイズと同じにする
        scrollView.contentSize = imageView.frame.size
    }

    // ImageView が常に画面中央に配置されるように contentInset を設定する
    private func updateContentInset() {
        scrollView.contentInset = UIEdgeInsets(
            top: max((scrollView.frame.height - imageView.frame.height) / 2, 0),
            left: max((scrollView.frame.width - imageView.frame.width) / 2, 0),
            bottom: 0,
            right: 0
        )
    }

    // ズーム対象のビューを返す
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView
    }

    // ズームのタイミングで contentInset を更新する
    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        updateContentInset()
    }
    
    // ScrollView がダブルタップされた時
    @objc private func scrollViewDoubleTapped(_ gesture: UITapGestureRecognizer) {
        guard let scrollView = gesture.view as? UIScrollView else { return }
        if scrollView.zoomScale == minZoomScale {
            // タップされた場所を中心に最大に拡大する
            let location = gesture.location(in: scrollView)
            let rect = zoomRect(for: scrollView, scale: maxZoomScale, center: location)
            scrollView.zoom(to: rect, animated: true)
        } else {
            // 最小に戻す
            scrollView.setZoomScale(minZoomScale, animated: true)
        }
    }
    
    private func zoomRect(for scrollView: UIScrollView, scale: CGFloat, center: CGPoint) -> CGRect {
        let size = CGSize(
            width: scrollView.frame.width / scale,
            height: scrollView.frame.height / scale
        )
        let rect = CGRect(
            origin: CGPoint(
                x: center.x - size.width / 2.0,
                y: center.y - size.height / 2.0
            ),
            size: size
        )
        return rect
    }
}

参考:

Cocoaの日々: 簡易スライドビューア [3] ダブルタップで拡大

Swift で 画像ビューワを実装する その1

検証環境:
Xcode 11.1
Swift 5.1

画像1枚を表示するシンプルな画像ビューワを実装してみる。
画像はピンチで拡大縮小ができるようにする。

f:id:xyk:20191011201756g:plain

実装方法としては UIScrollView 上に UIImageView を配置して実現する。
ただ乗せるだけだと画像の起点が左上になるが UIScrollView の contentInset を設定することで画面の中央に配置できる。

画像のズームは

  • スクロールビュー上のズームさせるビューを返すデリゲート viewForZoomingInScrollView
  • スクロールビューの minimumZoomScale、maximumZoomScale を設定

の2つを実装する必要がある。

コード

Storyboard側で UIScrollView を画面いっぱいに貼り付けてある。

class ImageViewerViewController: UIViewController, UIScrollViewDelegate {

    @IBOutlet private weak var scrollView: UIScrollView!

    private let minZoomScale: CGFloat = 1.0
    private let maxZoomScale: CGFloat = 4.0

    private var imageView: UIImageView = {
        let image = UIImage(named: "dog")!
        let imageView = UIImageView(image: image)
        // アスペクト比固定でフィットさせる
        imageView.contentMode = .scaleAspectFit
        return imageView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupScrollView()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        updateImageView()
        updateContentInset()
    }

    private func setupScrollView() {
        scrollView.delegate = self
        scrollView.backgroundColor = .black
        scrollView.minimumZoomScale = minZoomScale
        scrollView.maximumZoomScale = maxZoomScale
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.addSubview(imageView)
    }

    // 画像のアスペクト比を維持したまま ScrollView にぴったり収まるように ImageView のサイズを調整する
    private func updateImageView() {
        guard let size = imageView.image?.size else { return }
        let wRate = scrollView.bounds.width / size.width
        let hRate = scrollView.bounds.height / size.height
        let rate = min(wRate, hRate, 1)
        imageView.frame.size = CGSize(width: size.width * rate, height: size.height * rate)
        // contentSize を画像サイズと同じにする
        scrollView.contentSize = imageView.frame.size
    }

    // ImageView が常に画面中央に配置されるように contentInset を設定する
    private func updateContentInset() {
        scrollView.contentInset = UIEdgeInsets(
            top: max((scrollView.frame.height - imageView.frame.height) / 2, 0),
            left: max((scrollView.frame.width - imageView.frame.width) / 2, 0),
            bottom: 0,
            right: 0
        )
    }

    // ズーム対象のビューを返す
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return imageView
    }

    // ズームのタイミングで contentInset を更新する
    func scrollViewDidZoom(_ scrollView: UIScrollView) {
        updateContentInset()
    }
}

続き:
xyk.hatenablog.com

参考: UIScrollViewの中央にUIImageViewを配置しつつズーム可能にする - Qiita
https://qiita.com/wmoai/items/52b1901e62d28dae9f91

UINavigationController を使って深い階層の ViewController に一気に遷移する方法

検証環境:
Xcode 11.1
Swift 5.1

例えば Universal Links などの機能を使ってディープリンクで深い階層に一気に遷移させたい場合などに使える。
UINavigationController の setViewControllers(_:animated:) というメソッドがあるので、この引数に複数の ViewController を渡せばよい。

developer.apple.com

以下実装例。

まず、RootViewController には ViewControllerAクラスが設定されている。
ここに ViewControllerB、ViewControllerC、ViewControllerD の順に push したのと同じ状態を setViewControllers を使って作る。
pop で最終的に ViewControllerA にまで戻りたいのであれば、表示中の ViewControllerA についても配列の最初に追加する必要がある。

// ViewControllerA
@IBAction func handleButton(_ sender: UIButton) {
    let sb = UIStoryboard(name: "Main", bundle: nil)
    let vcB = sb.instantiateViewController(identifier: "ViewControllerB")
    let vcC = sb.instantiateViewController(identifier: "ViewControllerC")
    let vcD = sb.instantiateViewController(identifier: "ViewControllerD")
    navigationController?.setViewControllers([self, vcB, vcC, vcD], animated: true)
}

用意した遷移ボタン押下すると上記 handleButtonメソッドが呼ばれる。
挙動としては ViewControllerBViewControllerCはスキップし、ViewControllerDが表示される。
この時の ViewController のライフサイクルは、遷移元のViewControllerAViewControllerDのみ呼ばれる。

ViewControllerD:viewDidLoad()
ViewControllerA:viewWillDisappear(_:)
ViewControllerD:viewWillAppear(_:)
ViewControllerA:viewDidDisappear(_:)
ViewControllerD:viewDidAppear(_:)

この状態から戻るボタンで pop させるとViewControllerCの読み込みが開始される。

ViewControllerC:viewDidLoad()
ViewControllerD:viewWillDisappear(_:)
ViewControllerC:viewWillAppear(_:)
ViewControllerD:viewDidDisappear(_:)
ViewControllerC:viewDidAppear(_:)

さらに戻るとViewControllerBが読み込まれる。

ViewControllerB:viewDidLoad()
ViewControllerC:viewWillDisappear(_:)
ViewControllerB:viewWillAppear(_:)
ViewControllerC:viewDidDisappear(_:)
ViewControllerB:viewDidAppear(_:)

さらに戻ると最初のViewControllerAが表示される。

ViewControllerB:viewWillDisappear(_:)
ViewControllerA:viewWillAppear(_:)
ViewControllerB:viewDidDisappear(_:)
ViewControllerA:viewDidAppear(_:)

Swift で for-in ループ時にキャストする

環境: Swift5.1

例えば、あるビューに追加されている複数のサブビューの中から UIButton にキャストできるものだけ処理する場合

for case button as UIButton in view.subviews {
    // ボタンに対する処理。ボタン以外はスキップされる。
    // 例えばボタンの押下
    button.sendActions(for: .touchUpInside)
}

キャストまでは不要だが、あるクラス(またはサブクラス)時のみループ内を処理を実行する場合

for view in view.subviews where view is UIButton {
    // ビューに対する処理。ボタン以外はスキップされる。
    // 例えばボタンの削除
    view.removeFromSuperview()
}

Finder のコンテキストメニューに「Visual Studio Code で開く」を追加する

環境: macOS Mojave 10.14.6

Finder の右クリックで表示されるコンテキストメニューからVisual Studio Codeでファイルをすぐに開きたい。

Automator を使用することで Finder のコンテキストメニューに新たなメニューが追加できる。
Automator を起動し、ファイル -> 新規 -> クイックアクションを選択する。

f:id:xyk:20190926115532p:plain

ユーティリティ -> シェルスクリプトを実行 を選択し、右側のスペースにドラッグアンドドロップする。
- ワークフローが受け取る現在の項目: ファイルまたはフォルダ
- 検索対象: Finder.app
- シェル: /bin/bash
- 入力の引き渡し方法: 引数として
- スクリプト

for f in "$@"; do
  open -a 'Visual Studio Code' "$f"
done

f:id:xyk:20190926115725p:plain

Command+S で保存ダイアログを表示して、クイックアクション名として「Visual Studio Code で開く」と入力し、保存する。
これでコンテキストメニューに以下のように追加される。

f:id:xyk:20190926120749p:plain

作成したワークフローは~/Library/Servicesディレクトリ内にある。

こちらを参考にした。
qiita.com

AdMob の app-ads.txt を設定する

iOS アプリの場合の話。
現在の設定状態は AdMob 管理画面のアプリ->すべてのアプリを表示->APP-ADS.TXT タブから確認できる。
以下は設定方法についての記述部分。

f:id:xyk:20190920185742p:plain

これを読んで最初わからなかったのが、デベロッパー ウェブサイト」の設定はどこにするんだろうということ。
これは App Store Connect のアプリ申請画面にあるマーケティングURL」に入力すればOKだった。
ここを入力すると App Store のアプリページに以下のようにデベロッパWebサイト」というリンクとして表示される。

f:id:xyk:20190920190829p:plain

で、設定したドメインのルートに自分用のコードスニペットをコピペしたapp-ads.txtを置く。
これで設定完了したはずなのだが、なかなか管理画面上に反映されず。

ドキュメントには
AdMob によるお客様の app-ads.txt ファイルのクロールおよび確認が完了するまで、少なくとも 24 時間お待ちください。
と書いてあるのだが、結局反映されたのはストア公開してから4日後であった。。

認識されると以下のように、緑の丸がついた状態となる。

f:id:xyk:20190920185739p:plain

UITableView に行追加すると contentOffset がズレる時の対応

環境: Xcode10.3、Swift 5.0.1

UITableView で行の一番下までスクロールして、データの追加読み込み行い、行追加後の reloadData 時にカクっと画面位置がズレる場合がある。
セルの高さはすべて固定値の場合の話。

UITableView の rowHeightestimatedRowHeight はデフォルトで UITableView.automaticDimension になっているのでこれを固定すればよい。

コードでやる場合は viewDidLoad あたりで

tableView.rowHeight = 60
tableView.estimatedRowHeight = 0

のように設定する。

StoryBoard 上でやる場合は Automatic のチェックを外す。