- 投稿日:2020-10-20T21:16:47+09:00
ARKitの画面を録画する
実装
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) } })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.comCore MLを使ったアプリを作っています。
機械学習関連の情報を発信しています。
- 投稿日:2020-10-20T13:58:18+09:00
[Swift] DataはSubSequenceもDataなので…
はじめに
「
Data
のSubSequence
もData
」という事実1。知らないとバグを作る。知っていてもついうっかりバグを作る。なるべくそのバグを減らそうというための記事。実例から学ぶ
最初のバイトをゲットだ
Data
の最初のバイト(UInt8
)を表示する関数を考えてみましょう:printFirstByte.swiftimport 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.swiftimport 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])")
みたいな実装ではダメというのは上で見た通りです。渡されるdata
のstartIndex
が0
とは限らないからです。正しい方法としてはいくつかの実装例が思いつきます。
enumeration-解1.swiftimport 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.swiftimport 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回目)
まとめ
大切なのは「
startIndex
は0
とは限らない!」ということです。おまけ
こういった仕様は
Data
に限ったことではないので、汎用的に使えるようRandomAccessCollection
を拡張するのも手です。に実装例があるので参考までに…。
- 投稿日:2020-10-20T13:24:25+09:00
大規模な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にてニュースを表示します。
APIを叩いてレスポンスを待っている間はローディングの表示になります。
初期表示と、UITableViewを下に引っ張られた時です。
すなわちこのアプリは「ローディングの状態を持っている」ということです。
Modelのサンプルコード
- このアプリの問題領域であるGoogleニュースのRSSフィードを扱うクラスです。
- RSSフィードの解説については、こちらの記事がすばらしく分かりやすいです。
- Google News Rss(API)
- ViewModel、Viewには依存しません。
- 他の層に依存しないのでDI (Dependency Injection) しなくてもXCTestが書けます。
- だだしAPIのモック化は骨なので、私はXCTestでAPIのモック化が必要な時はOHHTTPStubsを使っています。
- ニュースフィードの取得結果はクロージャ(コールバック関数)で呼び元に返します。
- 実業務では、通知先が複数オブジェクトの場合などには、NotificationCenterを使う場合もあります。
Model.swiftimport 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.swiftimport 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.swiftimport 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
参考リンク
- 投稿日:2020-10-20T08:55:56+09:00
普通に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) -> URLSessionDataTaskCombineで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)") })終わり
- 投稿日:2020-10-20T01:13:51+09:00
タップした場所にフォーカスを合わせる
iPhoneのプリインストールのカメラアプリと同じように、タップした場所にフォーカスします。
手順
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)です。
タップした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.comCore MLを使ったアプリを作っています。
機械学習関連の情報を発信しています。