20201020のSwiftに関する記事は5件です。

ARKitの画面を録画する

画面をARコンテンツごと録画する方法です。
スクリーンショット 2020-10-20 12.07.35.png

実装

1、ReplayKitをインポート

ReplayKitを用います。

import ReplayKit

class ViewController: UIViewController,RPPreviewViewControllerDelegate...{

録画後プレビューのためのRPPreviewViewControllerDelegateも設定します。

2、ReplayKitの画面レコーダーを用意

let sharedRecorder = RPScreenRecorder.shared()

Info.plist の "Privacy - Microphone Usage Description" を追加してください。
追加せずにマイクを有効にすると、クラッシュします。

3、録画開始

sharedRecorder.isMicrophoneEnabled = true
sharedRecorder.startRecording(handler: { (error) in
    if let error = error {
        print(error)
    }
})

画面全体の収録を開始します。
スクリーンショット 2020-10-20 12.07.55.png

4、録画終了

sharedRecorder.stopRecording(handler: { (previewViewController, error) in
    previewViewController?.previewControllerDelegate = self
    self.present(previewViewController!, animated: true, completion: nil)
})

プレビューViewがポップアップします。
「保存」をタップで写真ライブラリに保存されます。
Info.plist の "Privacy - Photo Library Additions Usage Description" と "Privacy - Photo Library Usage Description"を追加してください。
追加せずに保存しようとすると、クラッシュします。

5、プレビューViewのデリゲート設定

func previewControllerDidFinish(_ previewController: RPPreviewViewController) {
    DispatchQueue.main.async { [unowned previewController] in
        previewController.dismiss(animated: true, completion: nil)
    }
}

「キャンセル」をタップでプレビューViewが消えるためのデリゲートメソッドです。

Tips

・録画音の設定

import AudioToolbox
...
    AudioServicesPlaySystemSound(1117) // 録画開始音
    AudioServicesPlaySystemSound(1118) // 録画終了音

・録画開始時に、不要なボタンなどを隠すと、綺麗にAR画面のみ録画できます。
iPhoneの時間やバッテリー表示などは録画されません。

?


お仕事のご相談こちらまで
rockyshikoku@gmail.com

Core MLを使ったアプリを作っています。
機械学習関連の情報を発信しています。

Twitter
Medium

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

[Swift] DataはSubSequenceもDataなので…

はじめに

DataSubSequenceData」という事実1。知らないとバグを作る。知っていてもついうっかりバグを作る。なるべくそのバグを減らそうというための記事。

実例から学ぶ

最初のバイトをゲットだ

Dataの最初のバイト(UInt8)を表示する関数を考えてみましょう:

printFirstByte.swift
import Foundation

func printFirstByte(of data: Data) {
  if data.isEmpty {
    print("からっぽだよ。")
  } else {
    print("最初のバイトは \(data[0]) だよ。")
  }
}

let data = Data([0, 1, 2, 3])
printFirstByte(of: data) // -> "最初のバイトは 0 だよ。"

一見すると良さそうですねぇ。
では、次の場合はどうでしょう?

printFirstByte(of: data.dropFirst()) // -> "最初のバイトは 1 だよ。"と表示される??

では、実際に実行してみましょう。
どうでしたか?
手元の環境では"?illegal hardware instruction"で落ちました。
…何故でしょうか?

理由を知るために、まず

// printFirstByte(of: data.dropFirst()) 
print(data.dropFirst().startIndex)

としてみてください。
表示された数字はなんでしょう?

"1" が表示されたはずです。

つまり、data.dropFirst()のインデックスは1から始まっているので、printFirstByte(of:)内のdata[0]の行で(インデックス0はout of boundsなので)クラッシュしたということになります。

SubSequence(別名Slice)を返す実装の多くは、まるまる内容をコピーするのではなく、「元のデータのこの部分」ということを指し示すようなインスタンスを返します。Dataも実際にそうなっていて、dropFirst()は元のデータのstartIndexを1増やしただけのインスタンスを返してきます2
元のデータかスライスデータのどちらかに変更が加えられるときになって初めてコピーが行われます。

正しい実装例

以上を踏まえprintFirstByte(of:)はどう実装すればよいのでしょうか?
一つの例としては、インデックスの値に頼らない方法を用いて実装することでしょう:

correct-printFirstByte.swift
import Foundation

func printFirstByte(of data: Data) {
  if let firstByte = data.first {
    print("最初のバイトは \(firstByte) だよ。")
  } else {
    print("からっぽだよ。")
  }
}

let data = Data([0, 1, 2, 3])
printFirstByte(of: data) // -> "最初のバイトは 0 だよ。"
printFirstByte(of: data.dropFirst()) // -> "最初のバイトは 1 だよ。"

ね、簡単でしょ?

バイトを列挙

スライスだろうとなんだろうと最初を0番目として表示したい場合は?
たとえば、[4, 5, 6, 7]というバイト列で

#0: 4
#1: 5
#2: 6
#3: 7

と表示させたいとき…。
print("#\(i): \(data[i])")みたいな実装ではダメというのは上で見た通りです。渡されるdatastartIndex0とは限らないからです。

正しい方法としてはいくつかの実装例が思いつきます。

enumeration-解1.swift
import Foundation

func printAllBytes(in data: Data) {
  for ii in 0..<data.count {
    print("#\(ii): \(data[data.startIndex + ii])") // startIndexを足さないとダメ
  }
}

let sourceData = Data([0,1,2,3,4,5,6,7,8,9])
printAllBytes(in: sourceData[4...7]) // 期待通り
enumeration-解2.swift
import Foundation

func printAllBytes(in data: Data) {
  for (ii, byte) in data.enumerated() {
    print("#\(ii): \(byte)")
  }
}

let sourceData = Data([0,1,2,3,4,5,6,7,8,9])
printAllBytes(in: sourceData[4...7]) // 期待通り

ね、簡単でしょ?(2回目)

まとめ

大切なのは「startIndex0とは限らない!」ということです。

おまけ

こういった仕様はDataに限ったことではないので、汎用的に使えるようRandomAccessCollectionを拡張するのも手です。

https://github.com/YOCKOW/ySwiftExtensions/blob/master/Sources/yExtensions/RandomAccessCollection%2BRelativeIndex.swift

に実装例があるので参考までに…。

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

大規模なiOSアプリでFatViewControllerを解消するために導入したMVVMパターンのサンプル

前書き

大規模なiOSアプリ開発を2年以上継続して得られた知見
上の記事ではアーキテクチャーパターンについては概要のみの記載でしたので、本記事で掘り下げます。

  • 「既存の作りをなるべく活かしてFatViewControllerを解消したい」と考えている方にとっては、一つの実践事例として参考になるかもしれません。
  • 一方で、教科書的な内容ではありませんので、本記事の内容を読んで「これが正しいMVVMだ」という理解はしない方が良いと思います。

前提環境:
・Xcode 12.0.1
・Swift 5.3

課題

  • アプリの画面数は120画面ぐらいです。ソコソコの規模かと思います。
  • UI/UXデザインには結構こだわっていて、一つ一つの画面実装が複雑です。
    • 一例として、API通信中はローディングプレースホルダー(スケルトン)を表示してユーザー操作を妨げないようにしています。すなわち画面全体をブロックをしないことでユーザーは他画面遷移等の操作が可能です。
  • 当初は標準的なCocoa MVCで作っていました。当然FatViewController化しました。
  • アプリのエンハンスメントを重ね規模が大きくなるにつれて改修が困難になってきました。

課題解決のための方針

FatViewControllerを解消して改修しやすくするために、アーキテクチャーパターンを導入することにしたわけです。

既存の作りを大幅に変えずにアーキテクチャーパターンを導入するためには、MVVMが最も適していると思われました。
既存のViewControllerからビジネスロジックをViewModelに切り出し、View-ViewModel間の通知を盛り込めば「イケる」と考えたためです。

また、Clean Architectureなどの、MVVMより複雑なパターンは学習コスト面でチームに合わないと考えました。

MVVMの適用に際して、以下のように方針を考えました。

  • 自前で作り込んでいたカスタムUIパーツやAutoLayoutのHelperがあるために、ライブラリの後付け導入は厳しいと考え、データバインディングは見限りました。
  • 元々ユニットテストがなく、XCUITestによるUIテストの自動化に取り組んでみたものの、機能追加変更の際のテストコードのメンテコストが大きく、私たちにとっては割りに合いませんでした。
  • 新たにXCTestでロジック部分のテストを書くようにしたいですが、View層のテストを書くことはやはりコストパフォーマンスが不安だったので、テストコードはロジック部分のみに絞り、UIは実動作で確認するよう割り切ることにしました。

ということで、
ライブラリ等を使わずに、自前でMVVM(的な?)アーキテクチャーを導入する方針で、FatViewControllerの解消を目指しました。

サンプルコードと簡単な解説

本記事のために作ったサンプルアプリです。
一応アーキテクチャーの説明のための要素は含んだつもりですが、実務のコードとは異なっていることをご了承ください。

サンプルアプリ

GoogleニュースのRSSフィードを取得して、UITableViewで一覧表示するアプリです。
セルを選択されるとSFSafariViewControllerにてニュースを表示します。
スクリーンショット 2020-10-19 11.48.06.pngスクリーンショット 2020-10-19 12.20.22.png
APIを叩いてレスポンスを待っている間はローディングの表示になります。
初期表示と、UITableViewを下に引っ張られた時です。
すなわちこのアプリは「ローディングの状態を持っている」ということです。
スクリーンショット 2020-10-19 11.48.02.png

Modelのサンプルコード

  • このアプリの問題領域であるGoogleニュースのRSSフィードを扱うクラスです。
    • RSSフィードの解説については、こちらの記事がすばらしく分かりやすいです。
    • Google News Rss(API)
  • ViewModel、Viewには依存しません。
  • 他の層に依存しないのでDI (Dependency Injection) しなくてもXCTestが書けます。
    • だだしAPIのモック化は骨なので、私はXCTestでAPIのモック化が必要な時はOHHTTPStubsを使っています。
  • ニュースフィードの取得結果はクロージャ(コールバック関数)で呼び元に返します。
    • 実業務では、通知先が複数オブジェクトの場合などには、NotificationCenterを使う場合もあります。
Model.swift
import Foundation

/// DIのためにModelの振る舞いを抽象化したProtocol
protocol ModelProtocol {
    func retrieveItems(completion: @escaping (Result<[Model.Article], Error>) -> Void)
    func createItems(with data: Data) -> Result<[Model.Article], Error>
}

/// アプリのドメイン(問題領域)のデータ保持と手続きを担う
class Model: NSObject, ModelProtocol {
    /// ニュース記事
    class Article {
        var title = ""
        var link = ""
        var pubDateStr = ""
        var pubDate: Date? {
            return createDate(from: pubDateStr)
        }
        var description = ""
        var source = ""
    }
    private var articles = [Article]()

    /// GoogleNEWSのXML要素の定義
    enum Element: String {
        case item = "item"
        case title = "title"
        case link = "link"
        case pubDate = "pubDate"
        case description = "description"
        case source = "source"

        var name: String {
            return self.rawValue
        }
    }
    private var currentElementName : String?

    // XMLパースで発生したエラー
    private var parseError: Error?

    /// GoogleNEWSのRSSを取得する
    func retrieveItems(completion: @escaping (Result<[Model.Article], Error>) -> Void) {
        let url = URL(string:  "https://news.google.com/rss?hl=ja&gl=JP&ceid=JP:ja")!
        URLSession.shared.dataTask(with: url, completionHandler: { [weak self] (data, response, error) in
            guard let self = self else {
                return
            }
            sleep(3)    // 擬似的なレスポンス遅延
            if let error = error {
                completion(Result.failure(error))
                return
            }
            guard let data = data else {
                completion(Result.success([Article]()))
                return
            }
            print("\(String(data: data, encoding: .utf8) ?? "decode error.")")    // DEBUG
            completion(self.createItems(with: data))
        }).resume()
    }

    /// GoogleNEWSのRSSを元にニュース記事の配列を生成する
    func createItems(with data: Data) -> Result<[Model.Article], Error> {
        let parser = XMLParser(data: data)
        parser.delegate = self
        parser.parse()
        if let parseError = parseError {
            return Result.failure(parseError)
        } else {
            return Result.success(articles)
        }
    }
}

// MARK: - XMLパーサーの処理群
extension Model: XMLParserDelegate {
    // 解析_開始時
    func parserDidStartDocument(_ parser: XMLParser) {
        articles.removeAll()
    }

    /// 解析_要素の開始時
    func parser(_ parser: XMLParser,
                didStartElement elementName: String,
                namespaceURI: String?,
                qualifiedName qName: String?,
                attributes attributeDict: [String : String]) {

        currentElementName = nil
        if elementName == Element.item.name {
            // 次のニュース記事が現れた場合、新規の記事classをデフォルトで生成
            articles.append(Article())
        } else {
            // 各要素の場合
            currentElementName = elementName
        }
    }

    /// 解析_要素内の値取得
    func parser(_ parser: XMLParser, foundCharacters string: String) {
        // 末尾の記事classを上書き更新
        guard let lastItem = articles.last else {
            return
        }
        switch currentElementName {
        case Element.title.name:
            lastItem.title = string
        case Element.link.name:
            lastItem.link = string
        case Element.pubDate.name:
            lastItem.pubDateStr = string
        case Element.description.name:
            lastItem.description = string
        case Element.source.name:
            lastItem.source = string
        default:
            break
        }
    }

    /// 解析_要素の終了時
    func parser(_ parser: XMLParser,
                didEndElement elementName: String,
                namespaceURI: String?,
                qualifiedName qName: String?) {

        currentElementName = nil
    }

    /// 解析_終了時
    func parserDidEndDocument(_ parser: XMLParser) {
        self.parseError = nil
    }

    /// 解析_エラー発生時
    func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) {
        self.parseError = parseError
    }
}

// MARK: - ユーティリティ関数
extension Model {
    /// GoogleNEWSの日付StringからDateを生成する
    static func createDate(from dateString: String) -> Date? {
        let formatter: DateFormatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .gregorian)
        formatter.dateFormat = "E, d M y HH:mm:ss z"
        return formatter.date(from: dateString)
   }
}

ViewModelのサンプルコード

  • Viewから送られたアクションを仲介してModelに問合せを行い、問合せ結果(ステータス:ロード中、成功、失敗)をViewに通知するクラスです。
  • Modelに依存しています。
    • XCTestのためにModelをDIできるようにしています。
    • サンプルコードのinit(:model)に着目してください。
  • Viewにて描画するのに必要な情報を加工して、保持します。
  • Viewへの問合せ結果(ステータス)の通知はViewModelDelegateを使っています。
ViewModel.swift
import Foundation

/// Viewにデータの取得状態が変化したことを通知するためのProtocol
protocol ViewModelDelegate: AnyObject {
    func didChange(status: Status)
}

/// データの取得状態
enum Status {
    case loading
    case loaded
    case error(String)
}

/// ViewとModelの間の情報の伝達と、Viewのための状態を保持する役割
class ViewModel {
    // Viewに提供するオブジェクト
    struct ViewItem {
        let title: String
        let link: String
        let source: String
        let pubDate: String?
    }
    private(set) var viewItems = [ViewItem]()

    // 取得状態を扱うオブジェクト
    weak var delegate: ViewModelDelegate?
    private(set) var status: Status? {
        didSet {
            // 随所でdelegate.didChange(:status)を呼び出すとモレる可能性があるのでdidSetにて行う
            guard let status = status else {
                return
            }
            delegate?.didChange(status: status)
        }
    }

    // テストのためにModelクラスをDIする
    private let model: ModelProtocol
    init(model: ModelProtocol = Model()) {
        self.model = model
    }

    /// データ取得
    func load() {
        status = .loading
        model.retrieveItems { [weak self] (result) in
            switch result {
            case .success(let items):
                self?.viewItems = items.map({ (article) -> ViewItem in
                    return ViewItem(title: article.title,
                                    link: article.link,
                                    source: article.source,
                                    pubDate: self?.format(for: article.pubDate))
                })
                self?.status = .loaded
            case .failure(let error):
                self?.status = .error("エラー: \(error.localizedDescription)")
            }
        }
    }
}

// MARK: - ユーティリティ関数
extension ViewModel {
    /// Dateから表示用文字列を編集する
    func format(for date: Date?) -> String? {
        guard let date = date else {
            return nil
        }
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy/MM/dd HH:mm"
        formatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
        formatter.locale = Locale(identifier: "en_US_POSIX")
        return formatter.string(from: date)
    }
}

Viewのサンプルコード

  • いわゆるプレゼンテーション層のクラスです。
  • ViewModelDelegateを実装することで、問合せ結果(ステータス)の通知を受け取り、画面に描画します。
  • 前述の通り、個々のView項目のバインディングは行いません。
  • また、Viewのテストコードを書くことは諦めているので、UITableViewDataSourceおよびUITableViewDelegateは「なり」で実装しています。
ViewController.swift
import UIKit
import SafariServices

class ViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!

    private let viewModel = ViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.dataSource = self
        tableView.delegate = self
        // 引っ張って更新
        tableView.refreshControl = UIRefreshControl()
        tableView.refreshControl?.addTarget(self, action: #selector(refresh(sender:)), for: .valueChanged)

        viewModel.delegate = self
        viewModel.load()
    }
}

// MARK: - UITableViewの処理群
extension ViewController: UITableViewDataSource, UITableViewDelegate {
    /// 行数を返す
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return viewModel.viewItems.count
    }

    /// cellを返す
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let identifier = "TableViewCell"
        let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath)
        let item = viewModel.viewItems[indexPath.row]
        cell.textLabel?.text = item.title
        cell.detailTextLabel?.text = "[\(item.source)] \(item.pubDate ?? "")"
        return cell
    }

    /// cellの選択時
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        guard let url = URL(string: viewModel.viewItems[indexPath.row].link) else {
            return
        }
        let safariVC = SFSafariViewController.init(url: url)
        safariVC.dismissButtonStyle = .close
        self.present(safariVC, animated: true, completion: nil)
        tableView.deselectRow(at: indexPath, animated: true)
    }
}

// MARK: - ViewModelDelegate
extension ViewController: ViewModelDelegate {
    /// ViewModelのステータスが変化した時の処理
    func didChange(status: Status) {
        switch status {
        case .loading:
            tableView.refreshControl?.beginRefreshing()
            tableView.reloadData()
        case .loaded:
            DispatchQueue.main.async { [weak self] in
                self?.tableView.refreshControl?.endRefreshing()
                self?.tableView.reloadData()
            }
        case .error(let message):
            DispatchQueue.main.async { [weak self] in
                self?.tableView.refreshControl?.endRefreshing()
            }
            print("\(message)")
        }
    }
}

// MARK: - Action
extension ViewController {
    /// UITableViewを引っ張って更新
    @objc func refresh(sender: UIRefreshControl) {
        viewModel.load()
    }
}

成果

以下の面において成果はあったと考えます。

  • FatViewControllerを解消し維持保守しやすくなった。
  • 既存の作りを大きく変えないことで、リアーキテクチャー工数と学習コストを抑えられた。
  • アーキテクチャーは変わったものの細部の実装は以前と変わらないので、新規機能開発の生産性は(ほぼ)下がらなかった。
  • ロジック部分のテストが書けるようになった。

リポジトリ

https://github.com/y-some/MVVMSample

参考リンク

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

普通にURLSessionとCombineでURLSession

概要

URLSessionの通常の書き方、Combineでの書き方について調べました。

題材

QiitaのLGTMの取得APIを例に利用します。
参考: Qiita APIで自分の記事のLGTMとViewを取得する。 - Qiita

現在LGTMが5つの記事のLGTMについて取得します。

https://qiita.com/api/v2/items/a9ead7285d10aadf5643/likes

このURLをGETで叩けば取得可能です。

普通のURLSession

import Foundation

let qiitaURL = URL(string: "https://qiita.com/api/v2/items/a9ead7285d10aadf5643/likes")!

struct LGTM: Codable {
    var created_at: String
    var user: User
}
struct User: Codable {
    var id: String
}

let session = URLSession.shared.dataTask(with: qiitaURL) {data, response, error in
    if let error = error {
        fatalError("Error: \(error.localizedDescription)")
    }
    guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
        fatalError("Error: invalid HTTP response code")
    }
    guard let data = data else {
        fatalError("Error: missing response data")
    }

    do {
        let lgtms = try JSONDecoder().decode([LGTM].self, from: data)
        lgtms.forEach{ lgtm in
            print("username: \(lgtm.user.id), created_at \(lgtm.created_at)")
        }
    }
    catch {
        print("Error: \(error.localizedDescription)")
    }
}
session.resume()

let sessionの型はURLSessionDataTaskです。
URLSessionDataTaskのresumeを呼び出すことで通信します。

URLSessionのメソッドdataTaskの定義は以下です。

open func dataTask(with url: URL, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask

CombineでURLSession

Combineで記述すると以下です。

import Foundation
import Combine

var cancellable = URLSession.shared
    .dataTaskPublisher(for: qiitaURL)
    .tryMap { element -> Data in
        guard let httpResponse = element.response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
        return element.data
    }
    .decode(type: [LGTM].self, decoder: JSONDecoder())
    .sink(receiveCompletion: {
        print("complete! \($0)")
    }, receiveValue: { lgtms in
        lgtms.forEach{ lgtm in
            print("username: \(lgtm.user.id), created_at \(lgtm.created_at)")
        }
    })

少し処理が変わってしまってますが、Combineの方がcompletionHandlerでの処理が少なくなり、見通しがよくなるように感じます。

Tips

JsonDecoderであるのかないのかわからない名前を指定する。

Jsonのフィールド名にないものを指定してしまうと、
Error: The data couldn’t be read because it is missing.とJsonDecoderがエラーを出します。

例えば次のようにstructを定義した場合です。

struct User: Codable {
    var id: String
    var aaa: String
}

こういう時は、ないかもしれないフィールド名をOptionalにすることでnilが入り、エラーを防ぐことが可能です。

struct User: Codable {
    var id: String
    var aaa: String?
}

URLSession.sharedとは?

URLSession.sharedはサクッと書きたいときに使うsingletonです。
デフォルトの挙動で動くため、高度な処理ができません。

  • サーバーからの逐次のデータ取得はできない
  • 大きくカスタマイズできない
  • 認証関係に制限あり
  • アプリが動いていないときにダウンロードやアップロードできない

shared | Apple Developer Documentation

element -> Data とは何をしている?

クロージャの引数を明示しているだけです。
-> Dataの部分がなくても動きます。

Closures — The Swift Programming Language (Swift 5.3)

配列の要素をひとつ1つ処理したい

CombineでURLSessionのsubscribe処理は、現在forEachによる入れ子構造となっています。

.decode(type: [LGTM].self, decoder: JSONDecoder())
.sink(receiveValue: { lgtms in
        lgtms.forEach{ lgtm in
            print("username: \(lgtm.user.id), created_at \(lgtm.created_at)")
        }
    }

こちらの入れ子をなくすには、flatMapが利用できます。

.decode(type: [LGTM].self, decoder: JSONDecoder())
.flatMap {$0.publisher}
.sink(receiveCompletion: {
    print("complete! \($0)")
}, receiveValue: { lgtm in
    print("username: \(lgtm.user.id), created_at \(lgtm.created_at)")
})

終わり

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

タップした場所にフォーカスを合わせる

iPhoneのプリインストールのカメラアプリと同じように、タップした場所にフォーカスします。
Oct-20-2020 01-03-47.gif

手順

1、タップでView内の座標を取る

UITapGestureRecognizerをカメラのプレビューレイヤーを含むViewに加えます。
タップで以下の関数を呼びます。

@objc func tapForcus(_ recognizer:UITapGestureRecognizer) {
let pointInView = recognizer.location(in: recognizer.view)
print(pointInView)
// (562.0, 282.0)
}

2、View内のタップ座標を、AVCapturePreviewLayer内の座標(0〜1)に正規化する

カメラ画像領域の座標は、常にLandScapeLeftの左上が(0,0)右下が(1,1)です。
スクリーンショット 2020-10-19 23.51.20.png
タップしたView座標をカメラ画像領域の座標に変換するには、AVCapturePreviewLayerの座標変換メソッドが使えます。

let pointInCamera = previewLayer?.captureDevicePointConverted(fromLayerPoint: pointInView)
print(pointInCamera)
// Optional((0.38380281690140844, 0.41796875))

3、取得した座標にフォーカスを合わせる

フォーカスの関心ポイントをタップしたポイントに設定してから、フォーカスモードを設定しなおします。
関心ポイントを設定した後で、都度フォーカスモードを設定します。でないと、フォーカスされません。

// avCaptureDeviceは、当該セッションのAVCaptureDevice

do {
    try avCaptureDevice.lockForConfiguration()
    avCaptureDevice.focusPointOfInterest = pointInCamera!
    avCaptureDevice.focusMode = .autoForcus // .autoForcus(固定) もしくは .continuousAutoFocus(デバイスによる自動監視継続)
    avCaptureDevice.unlockForConfiguration()
} catch let error {
    print(error)
}

おまけ:フォーカスアニメーション

フォーカスビューの設定

var forcusView = UIView()
var forcusView = UIView()
forcusView.frame = CGRect(x: 0, y: 0, width: view.bounds.width * 0.3, height: view.bounds.width * 0.3)
forcusView.layer.borderWidth = 1
forcusView.layer.borderColor = UIColor.systemYellow.cgColor
forcusView.isHidden = true

タップでアニメーション

// @objc func tapForcus(_ recognizer:UITapGestureRecognizer) {
// let pointInView = recognizer.location(in: recognizer.view)

forcusView.center = pointInView // タップしたポイントへ移動する
forcusView.isHidden = false // 現れる
UIViewPropertyAnimator.runningPropertyAnimator(withDuration: 0.5, delay: 0, options: []) {
   self.forcusView.frame = CGRect(x: point.x - (self.view.bounds.width * 0.075), y: point.y - (self.view.bounds.width * 0.075), width: (self.view.bounds.width * 0.15), height: (self.view.bounds.width * 0.15)) // タップしたポイントに向けて縮む
} completion: { (UIViewAnimatingPosition) in
   Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { (Timer) in
        self.forcusView.isHidden = true // 少し待ってから消える
        self.forcusView.frame.size = CGSize(width: self.view.bounds.width * 0.3, height: self.view.bounds.width * 0.3) // 少し大きめのサイズに戻しておく
    }

?


お仕事のご相談こちらまで
rockyshikoku@gmail.com

Core MLを使ったアプリを作っています。
機械学習関連の情報を発信しています。

Twitter
Medium

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