xyk blog

最近は iOS 開発の記事が多めです。

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

検証環境:
Xcode 11.1
Swift 5.1

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

https://developer.apple.com/library/archive/documentation/WindowsViews/Conceptual/UIScrollView_pg/ZoomZoom/ZoomZoom.html#//apple_ref/doc/uid/TP40008179-CH102-SW7developer.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()
        
        adjustImageViewSize()
        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 adjustImageViewSize() {
        guard let size = imageView.image?.size, imageView.frame.isEmpty 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 のサイズが ScrollView の表示範囲より小さい場合に画面中央に配置されるように 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 の実装
  • スクロールビューの minimumZoomScalemaximumZoomScale を設定

の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()
        
        adjustImageViewSize()
        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 adjustImageViewSize() {
        guard let size = imageView.image?.size, imageView.frame.isEmpty 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 のサイズが ScrollView の表示範囲より小さい場合に画面中央に配置されるように 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 let 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 のチェックを外す。

iPhone のマイクから拾った音の音程を判定する

環境: Xcode10.3、Swift 5.0.1

iPhone のマイクから拾ったオーディオ情報から音程を判定する方法について調べた。
ちゃんとやるには、離散フーリエ変換 (discrete Fourier transform) を使って周波数を算出するらしいのだけれども、今回はAudioKitというOSSライブラリを使うことで簡単に実現できたのでメモ。

github.com

やりたいことがそのまま公式Example(GitHubコードはこちら)として実装されていたのでこの通りに進めた。

audiokit.io

画面にはマイクから拾った音の周波数(Frequency)と音階(Note)、また波形がリアルタイムで表示される。

AudioKit インストール

プリコンパイル済みのFrameworkはこちらからダウンロードできる。
プロジェクトにダウンロードしたAudioKit.frameworkを追加、
そして TARGET の Build Settings > Linking > Other Linker Flags に -lc++ の追加する。

CocoaPods の場合

Podfile

pod 'AudioKit', '~> 4.0'

インストール

$ pod install

Carthage の場合

Cartfile

github "AudioKit/AudioKit"

ビルド

# iOS
$ carthage update --platform iOS --no-use-binaries --cache-builds --new-resolver
# Mac
$ carthage update --platform Mac --no-use-binaries --cache-builds --new-resolver

さらに TARGET の Build Settings > Linking > Other Linker Flags に -lc++ の追加が必要。

Swift コード

Exampleのコードそのまま。今回は波形表示は不要だったので省いた。
AKFrequencyTrackerクラスを利用することでピッチ検出することができる。
ただし、現状は検出できるのは単音(モノフォニック)のみで、和音のような複数音(ポリフォニック)には対応していないようだ。

import UIKit
import AudioKit

class ViewController: UIViewController {

    @IBOutlet weak var frequencyLabel: UILabel!
    @IBOutlet weak var amplitudeLabel: UILabel!
    @IBOutlet weak var noteNameWithSharpsLabel: UILabel!
    @IBOutlet weak var noteNameWithFlatsLabel: UILabel!
    
    var mic: AKMicrophone!
    var tracker: AKFrequencyTracker!
    var silence: AKBooster!
    
    let noteFrequencies = [16.35, 17.32, 18.35, 19.45, 20.6, 21.83, 23.12, 24.5, 25.96, 27.5, 29.14, 30.87]
    let noteNamesWithSharps = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]
    let noteNamesWithFlats = ["C", "D♭", "D", "E♭", "E", "F", "G♭", "G", "A♭", "A", "B♭", "B"]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        AKSettings.audioInputEnabled = true
        mic = AKMicrophone()
        tracker = AKFrequencyTracker(mic)
        silence = AKBooster(tracker, gain: 0)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        AudioKit.output = silence
        do {
            try AudioKit.start()
        } catch {
            AKLog("AudioKit did not start!")
        }
        Timer.scheduledTimer(timeInterval: 0.1,
                             target: self,
                             selector: #selector(ViewController.updateUI),
                             userInfo: nil,
                             repeats: true)
    }
    
    @objc func updateUI() {
        if tracker.amplitude > 0.1 {
            frequencyLabel.text = String(format: "%0.1f", tracker.frequency)
            
            var frequency = Float(tracker.frequency)
            while frequency > Float(noteFrequencies[noteFrequencies.count - 1]) {
                frequency /= 2.0
            }
            while frequency < Float(noteFrequencies[0]) {
                frequency *= 2.0
            }
            
            var minDistance: Float = 10_000.0
            var index = 0
            
            for i in 0..<noteFrequencies.count {
                let distance = fabsf(Float(noteFrequencies[i]) - frequency)
                if distance < minDistance {
                    index = i
                    minDistance = distance
                }
            }
            let octave = Int(log2f(Float(tracker.frequency) / frequency))
            noteNameWithSharpsLabel.text = "\(noteNamesWithSharps[index])\(octave)"
            noteNameWithFlatsLabel.text = "\(noteNamesWithFlats[index])\(octave)"
        }
        amplitudeLabel.text = String(format: "%0.2f", tracker.amplitude)
    }
}

コード内にあるnoteFrequencies配列の数値の意味がわからなかったが、調べたところ音高(Pitch)の周波数らしい。
オクターブ 4 のラの音A4 = 440Hzを基準音とすると以下の表のようになる。
1オクターブ高いと周波数は2倍になる。

Frequency (Hz)
OctaveNote
C C# D Eb E F F# G G# A Bb B
0 16.35 17.32 18.35 19.45 20.60 21.83 23.12 24.50 25.96 27.50 29.14 30.87
1 32.70 34.65 36.71 38.89 41.20 43.65 46.25 49.00 51.91 55.00 58.27 61.74
2 65.41 69.30 73.42 77.78 82.41 87.31 92.50 98.00 103.8 110.0 116.5 123.5
3 130.8 138.6 146.8 155.6 164.8 174.6 185.0 196.0 207.7 220.0 233.1 246.9
4 261.6 277.2 293.7 311.1 329.6 349.2 370.0 392.0 415.3 440.0 466.2 493.9
5 523.3 554.4 587.3 622.3 659.3 698.5 740.0 784.0 830.6 880.0 932.3 987.8
6 1047 1109 1175 1245 1319 1397 1480 1568 1661 1760 1865 1976
7 2093 2217 2349 2489 2637 2794 2960 3136 3322 3520 3729 3951
8 4186 4435 4699 4978 5274 5588 5920 6272 6645 7040 7459 7902

ピアノの鍵盤数は88で7オクターブ、この表での範囲はA0 = 27.50HzからC8 = 4186Hzになる。
ちなみに日本のピアノはA4 = 442Hzで調律されることが多いとのこと。

https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Piano_Frequencies.svg/520px-Piano_Frequencies.svg.png

水色が真ん中のド (C4)、英語では middle C と言う、黄色がラ (A4)

参考:

A440 - Wikipedia