UIDatePickerのdatePickerModeをcountDownTimerにした時の挙動確認
今回はUIDatePickerのdatePickerModeをcountDownTimerに変えた時の挙動を見てみました。
まずはdatePickerModeに何もセットしない場合の挙動を見ます。
プロジェクトをViewControllerを作って下のように変更します。
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let picker = UIDatePicker() picker.center = view.center view.addSubview(picker) } }
下のようにデフォルトのDatePickerが表示されます。
変更時のイベントの受け取り処理は下のとおりです。
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let picker = UIDatePicker() picker.center = view.center view.addSubview(picker) picker.addTarget(self, action: #selector(self.datePickerValueChanged(sender:)), for: .valueChanged) } @objc func datePickerValueChanged(sender: UIDatePicker) { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy/MM/dd HH:mm" print(dateFormatter.string(from: sender.date)) } }
変更してから日付を変更するとコンソールに変更後の日付が出てきます。
今回はこのUIDatePickerのdatePickerModeをcountDownTimerにした時の動作を確認します。
picker.datePickerMode = .countDownTimer
変更すると下のような表示になります。
試しに数字を10時間6分に変えたら下のような表示になりました。
おそらくDatePickerのdateは当日の選択時間になるのかと思います。
次はPickerに現在の日時(19時40分)をセットしてみました。
picker.date = Date()
セットしたら下のように現在の時間と同じ時間になりました。
Storyboard+UIScrollView+AutoLayoutでhas ambiguous scrollable contentエラーが出ないようにする
Storyboard上でUIScrollView+AutoLayoutを使うと下のようなエラーが出る事があります。
今回はその対処法を調べました。
Xcodeのバージョンは13です。
参考にしたのは下URLです。
まずはプロジェクトを作ってMain.storyboardにUIScrollViewを配置します。
今回はUIScrollViewを縦にだけスクロールするようにしようと思います。
配置したらUIScrollViewに下のような制約を追加します。
制約を追加すると下のようにエラーになり線が赤くなります。
エラー対策の為、まずはUIViewをUIScrollViewの直下に配置します。
次に左側のメニューでViewとContent Layout Guideを選びます。
この状態で下のような制約を追加します。
続けてViewにHeightの制約を追加します。
これでyやheightに関するエラーは消えました。
次はxとwidthに関するエラーの対応をします。
今度はViewとFrame Layout Guideを選びます。
この状態でEqual Widthsの制約を追加します。
これでエラーが消えました。
実行するとスクロールのContentHeightが1000になっている事がわかります。
先程出てきたContent Layout GuideとFrame Layout Guideですが公式サイトに説明がありました。
https://developer.apple.com/documentation/uikit/uiscrollview/2865870-contentlayoutguide
https://developer.apple.com/documentation/uikit/uiscrollview/2865772-framelayoutguide
公式サイトの説明は下の通りです。
要するにContent Layout GuideはUIScrollViewのcontentSizeに関するガイドでFrame Layout GuideはUIScrollViewのframeに関するガイドです。
そのためFrame Layout GuideとViewにEqualWidthsを付ければcontentSize.widthがUIScrollViewのframe.widthと等しくなったのだと思われます。
■ Content Layout Guide Use this layout guide when you want to create Auto Layout constraints related to the content area of a scroll view. ■ Frame Layout Guide Use this layout guide when you want to create Auto Layout constraints that explicitly involve the frame rectangle of the scroll view itself, as opposed to its content rectangle.
続けてContentHeightを動的に変える方法についても見ていきます。
まずはViewControllerにNSLayoutConstraintのIBOutletを作ります。
class ViewController: UIViewController { @IBOutlet var constraint: NSLayoutConstraint! }
Storyboardから今作ったIBOutletとHeightに関するNSLayoutConstraintを紐付けます。
あとはNSLayoutConstraintの値を変えればContentHeightの値が変わります。
class ViewController: UIViewController { @IBOutlet var constraint: NSLayoutConstraint! override func viewDidLoad() { super.viewDidLoad() constraint.constant = 2000 } }
Android StudioのCPU Profilerで重い箇所を特定する
下URLを参考にCPU Profilerを試してみました。
まずは新規プロジェクトを作成します。
Profiler画面は[View] > [Tool Windows] > [Profiler]と選択することで表示できます。
画面は最初は下のようになっています。
アプリを立ち上げると自動で測定開始してくれます。
測定しない場合、左上の+ボタンからセッションを選択します。
CPUを押すとCPU利用状況についてより詳しく見れるようになります。
より詳しく見たい場合、真ん中上部のRecordボタンで測定します。
測定停止したい所でStopボタンを押します。
Stopボタンを押すと下のように詳しく見れる画面に移動します。
テスト用に下のような重い処理を追加します。
view.findViewById<Button>(R.id.button_first).setOnClickListener { (0..100000).forEach { i -> (0..10000).forEach { j -> val r = i.toFloat() * j.toFloat() } } findNavController().navigate(R.id.action_FirstFragment_to_SecondFragment) }
上を追加して測定した結果は下の通りです。
右側の画面でTop Downタブを開くと具体的にどの処理が重いかまでを追いかける事ができます。
StoreKit2のサンプルコードを動かしてみる
今回は下URLからダウンロードできるStoreKit2のサンプルコードを動かしてみました。
StoreKit2は旧来のStoreKitを改良したもので、シンプルなコードで購入処理が記述できるようになっています。
ただしiOS15限定以上なので利用する際はアプリの対象OSを15以上にするか旧StoreKitと併用する必要があります。
StoreKit2の処理
商品情報の取得方法は下の通りです。
こちらはサンプルコードを少し分かりやすく改変したものになります。
StoreKit2ではawaitを使う事でブロックやDelegateを使わずに取得できるようになりました。
let products = try? await Product.products(for: ["com.example"])
購入処理下の通りです。
purchaseメソッドだけで購入できるので今までに比べて非常に楽になりそうです。
戻り値の型はVerificationResult
func purchase(_ product: Product) async throws -> Transaction? { //Begin a purchase. let result = try await product.purchase() switch result { case .success(let verification): let transaction = try checkVerified(verification) //Deliver content to the user. await updatePurchasedIdentifiers(transaction) //Always finish a transaction. await transaction.finish() return transaction case .userCancelled, .pending: return nil default: return nil } } func checkVerified<T>(_ result: VerificationResult<T>) throws -> T { //Check if the transaction passes StoreKit verification. switch result { case .unverified: //StoreKit has parsed the JWS but failed verification. Don't deliver content to the user. throw StoreError.failedVerification case .verified(let safe): //If the transaction is verified, unwrap and return it. return safe } }
過去の購入情報はTransactionのlatestメソッドで取得する事ができます。
購入時同様、VerificationResult
func isPurchased(_ productIdentifier: String) async throws -> Bool { //Get the most recent transaction receipt for this `productIdentifier`. guard let result = await Transaction.latest(for: productIdentifier) else { //If there is no latest transaction, the product has not been purchased. return false } let transaction = try checkVerified(result) //Ignore revoked transactions, they're no longer purchased. //For subscriptions, a user can upgrade in the middle of their subscription period. The lower service //tier will then have the `isUpgraded` flag set and there will be a new transaction for the higher service //tier. Ignore the lower service tier transactions which have been upgraded. return transaction.revocationDate == nil && !transaction.isUpgraded } func checkVerified<T>(_ result: VerificationResult<T>) throws -> T { //Check if the transaction passes StoreKit verification. switch result { case .unverified: //StoreKit has parsed the JWS but failed verification. Don't deliver content to the user. throw StoreError.failedVerification case .verified(let safe): //If the transaction is verified, unwrap and return it. return safe } }
まとめ
簡単に動かしてみましたが従来に比べて処理がシンプルになりそうで良かったです。
シンプルんになっただけでなく返金対応が増えていたり継続課金の検証が簡単だったりするようなので実際に使う時は更に詳しく調べていきたいと思いました。
ShazamKitで音声認識を試してみる
下の記事を参考にShazamKitを動かしてみました。
ShazamKitは音楽認識ライブラリで、マイク経由で曲を渡すと曲名を出してくれたりします。
ShazamKitのiOS15から登場したライブラリで、SDKはAndroidでも使えるようです。
初期設定
まずは使いたいアプリのBundleIDのShazamKitを有効化します。
AppleのMemberCenterにアクセスしてShazamKitを使いたいBundleIDを登録してから「App Services」にあるShazamKitにチェックを入れて保存します。
実装
新規にプロジェクトを作成します。
BundleIDは先程登録したIDを使います。
音声認識の際にマイクを使うので、Info.plistにNSMicrophoneUsageDescriptionを追加してマイクを使う理由を記述します。
まずはマイクの音を取り込む実装をします。
ViewControllerを下のように修正します。
class ViewController: UIViewController { let engine = AVAudioEngine() override func viewDidLoad() { super.viewDidLoad() let audioSession = AVAudioSession.sharedInstance() try? audioSession.setCategory(.record) try? audioSession.setActive(true, options: .notifyOthersOnDeactivation) let inputNode = engine.inputNode try? engine.start() } }
起動するとマイクの音声を読み取る事ができるようになります。
次は読み取った音声を元に曲名を調べる実装をします。
ViewControllerを下のように変更します。
import UIKit import ShazamKit class ViewController: UIViewController { let session = SHSession() // 追加 let engine = AVAudioEngine() override func viewDidLoad() { super.viewDidLoad() let audioSession = AVAudioSession.sharedInstance() try? audioSession.setCategory(.record) try? audioSession.setActive(true, options: .notifyOthersOnDeactivation) let inputNode = engine.inputNode try? engine.start() // ここから追加 session.delegate = self let format = inputNode.outputFormat(forBus: .zero) inputNode.installTap(onBus: .zero, bufferSize: 1024, format: format) { buffer, _ in self.session.matchStreamingBuffer(buffer, at: nil) } } } extension ViewController: SHSessionDelegate { func session(_ session: SHSession, didFind match: SHMatch) { print(#function) print(match.mediaItems) } func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: Error?) { print(#function) print(error) } }
音声をShazamKitにわたす処理は下の箇所で行っています。
inputNode.installTap(onBus: .zero, bufferSize: 1024, format: format) { buffer, _ in self.session.matchStreamingBuffer(buffer, at: nil) }
読み取り成功時や失敗時はSHSessionDelegateのメソッドが呼ばれます。
extension ViewController: SHSessionDelegate { func session(_ session: SHSession, didFind match: SHMatch) { print(#function) print(match.mediaItems) } func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: Error?) { print(#function) print(error) } }
曲を流すと下のように正しく曲が認識されました。
日本の曲も試した所、正しく認識する事ができました。
Docker+Railsでサーバー立ち上げメモ
Docker+Railsでサーバーを立ち上げた時のメモです。
元々Dockerを使わずに運営していたのですが、CentOS6がサポート切れになったことやbundle installでよく苦戦する事もあって環境を作り直す事にしました。
今回のOSはmacOSになります。
今回の構成ですがまずはCentOSのImageにRailsやMySQLを乗せていく形で作っていきます。
DBやアプリケーションサーバーを分ける構成が推奨されているのですが、いきなり分割するのは難易度が高いので最初は1つのコンテナにすべて詰め込みます。
ローカル環境構築
まずはMacにDockerをインストールします。
インストールは下のサイトから行いました。
続けてDockerfileを作っていきます。
ここではrbenvを使ったRubyのインストールをしています。
下の方にあるENVはパスを通すためのコマンドです。
Dockerの実行中は.bash_profileを読んでくれないのでENVコマンドでパスを通しています。
FROM centos:8 # Ruby RUN yum -y install git gcc make bzip2 openssl-devel readline-devel zlib-devel RUN git clone https://github.com/sstephenson/rbenv.git ~/.rbenv RUN cd ~/.rbenv && src/configure && make -C src RUN git clone https://github.com/sstephenson/ruby-build.git ~/.rbenv/plugins/ruby-build RUN echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile && echo 'eval "$(rbenv init -)"' >> ~/.bash_profile ENV PATH /root/.rbenv/bin:/root/.rbenv/shims:$PATH RUN rbenv install 2.5.1 && rbenv global 2.5.1
続けて下コマンドでImageを作成します。
tオプションはImageの名前やタグを指定するためのものです。
docker build -t my_image .
buildの後にdocker imagesコマンドを打つとImageが作られている事が分かります。
Imageを消したい場合はdocker rmi xxx(ImageのIDか名前)で削除する事ができます。
ImageIDは先頭の数文字だけでも問題ありません。
すべて一気に削除したい時は下コマンドが便利です。
docker images -aq | xargs docker rmi
次はImageからコンテナを作成して起動します。
起動は下のコマンドです。
docker run -itd --name my_container -p 3000:3000 --privileged my_image /sbin/init
http://localhost:3000
でDockerの3000ポートに繋ぎたかったので下のように-pオプションでポートを指定しています。
他オプションですがdはバックグラウンド起動のため、iはホストの入力をコンテナに送るため、tはコンテナからの出力を受けるためのものです。
--privilegedはホスト側などへのアクセス権限を与えるオプションです。
マウント周りで権限関連のエラーが出たので追加したのですが、本番環境などでは避けた方が良いかもしれません。
コンテナへのアクセスする場合は下コマンドを使います。
loginオプションはbash_profileの読み込みの為に入れています、ただDockerfileのENVでRubyのパスを追加したので除いてもいいかもしれません。
docker exec -it my_container bash --login
試しにRubyバージョンを確認した所、無事に2.5.1が入っていました。
execを使うとコンテナ上でコマンドを実行します。
そのためbash --loginをlsに置き換えると下のようにファイル一覧を表示します。
立ち上げたコンテナですが停止したい時はdocker stop xxx(コンテナのIDか名前)、削除したい時はdocker rm xxx(コンテナのIDか名前)を使います。
起動中のコンテナを削除したい時は下のようにrmにfオプションを付けます。
docker rm -f xxx(コンテナのIDか名前)
次にMySQLをインストールします。
Dockerfileの下部に下の行を追加してImageを作り直して実行します。
# MySQL RUN yum -y install mysql mysql-server
起動すると下のように実行できるようになっています。
最後にRailsを導入します。
Dockerfile最下部に下の処理を追加します。
# Rails RUN yum -y install mysql-devel gcc-c++ nodejs crontabs wget fontconfig-devel RUN wget https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 && tar jxf phantomjs-2.1.1-linux-x86_64.tar.bz2 && cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/bin/ RUN gem install bundler --version=1.16.3 RUN mkdir /my_app WORKDIR /my_app COPY . /my_app RUN bundle install
rails s の-bオプションは127.0.0.1で立ち上げるとうまく動かないためです。
こちらは下サイトを参考にしました。
docker上のアプリにlocalhostでアクセスしたらERR_EMPTY_RESPONSEが出る - Qiita
この状態でコンテナに入り下コマンドを実行するとサーバーを起動できます。
systemctl start mysqld rake db:create && rake db:migrate rake db:seed bin/rails s -b 0.0.0.0
MySQLのデータの永続化
次はデータベースのデータの永続化をします。
今はコンテナを削除するとデータが消えてしまうので、volumeを作る事で永続化しようと思います。
最初はホスト側にデータを保存しようと思ったのですが、MySQLの起動がうまくいかなかったのでvolumeを作る事にしました。
まずは下コマンドでvolumeを作成します。
docker volume create my_volume
docker volume lsコマンドを打つと今作ったvolumeを確認する事ができます。
削除はdocker volume rmコマンドを使います。
volumeはdocker runの-vオプションで指定します。
今回はMySQLのデータ部分をvolumeに保存したかったので-v my_volume:/var/lib/mysqlというオプションを付けました。
docker run -itd --name my_container -p 3000:3000 -v my_volume:/var/lib/mysql --privileged my_image /sbin/init
これでコンテナを作り直してもデータを保持するようになりました。
Docker Composeでコンテナを管理
次はDocker Composeを使ってコンテナを管理します。
Docker Composeを複数のコンテナを管理できるツールです。
まずは下コマンドでDocker Composeが入っていることを確認します。
入ってない場合は各環境に応じた方法でインストールします。
docker-compose version
次はDocker Composeの設定ファイルであるdocker-compose.ymlを作成して下のように記述します。
version: '3' services: app: container_name: my_container privileged: true volumes: - my_volume:/var/lib/mysql ports: - 3000:3000 command: /sbin/init build: . volumes: my_volume:
起動コマンドは下のとおりです。
先程使っていたdocker runに比べると非常にシンプルになりました。
docker-compose up -d
コンテナの停止と削除は下コマンドになります。
docker-compose down
Imageの削除も行いたい場合は下のオプションを追加します。
docker-compose down --rmi all
データベースを別コンテナ化
Docker Composeを入れたので、コンテナもアプリケーションとデータベースで分けたいと思います。
下のようにdocker-compose.ymlにデータベースコンテナを追加します。
ホスト側からDBにアクセスしない場合はportsの部分は削除して構いません。
environmentのMYSQL_USERとMYSQL_PASSWORDではユーザーを作成しています。
MYSQL_DATABASEでデータベースの作成とそのデータベースへの権限付与を行います。
appの方にはdepends_onを追加します。
これによってdbを起動してからappを起動できるようになります。
version: '3' services: app: container_name: my_container_app privileged: true volumes: - my_volume:/var/lib/mysql ports: - 3000:3000 command: /sbin/init build: . depends_on: - "db" db: container_name: my_container_db image: mysql:8.0 volumes: - my_volume:/var/lib/mysql environment: MYSQL_USER: user MYSQL_PASSWORD: password MYSQL_DATABASE: my_db MYSQL_ROOT_PASSWORD: password hostname: db_container ports: - 3306:3306 volumes: my_volume:
起動するとデータベースのコンテナも作られている事が分かります。
ホスト側からは下コマンドでアクセスできるようになります。
mysql -h localhost -u test --protocol=tcp -p
次はアプリケーション用コンテナから今作ったデータベースを見るようにします。
まずはDockerfileから下のようにMySQL関連の処理を削除します。
FROM centos:8 # Ruby RUN yum -y install git gcc make bzip2 openssl-devel readline-devel zlib-devel RUN git clone https://github.com/sstephenson/rbenv.git ~/.rbenv RUN cd ~/.rbenv && src/configure && make -C src RUN git clone https://github.com/sstephenson/ruby-build.git ~/.rbenv/plugins/ruby-build RUN echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bash_profile && echo 'eval "$(rbenv init -)"' >> ~/.bash_profile ENV PATH /root/.rbenv/bin:/root/.rbenv/shims:$PATH RUN rbenv install 2.5.1 && rbenv global 2.5.1 # Rails RUN yum -y install mysql-devel gcc-c++ nodejs crontabs wget fontconfig-devel RUN wget https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2 && tar jxf phantomjs-2.1.1-linux-x86_64.tar.bz2 && cp phantomjs-2.1.1-linux-x86_64/bin/phantomjs /usr/bin/ RUN gem install bundler --version=1.16.3 RUN mkdir /my_app WORKDIR /my_app COPY . /my_app RUN bundle install
Railsのdatabase.ymlにDBコンテナのパスワードやホスト情報を追加します。
default: &default adapter: mysql2 reconnect: false pool: 5 username: user password: password host: db_container development: <<: *default database: my_db test: <<: *default database: my_db production: <<: *default database: my_db
これでデータベースのコンテナを分割する事ができました。
あとは下コマンドでアプリサーバーを立ち上げれば完了です。
rake db:create && rake db:migrate rake db:seed bin/rails s -b 0.0.0.0
起動に関する処理の自動化
次は上に記載したrails sなども自動化していきます。
まずは自動化用のファイルdocker_app_command.shを作成して下のように記述します。
#!/bin/sh rake db:create && rake db:migrate rake db:seed bin/rails s -b 0.0.0.0
ファイルには実行権限を与えておきます。
chmod 755 docker_app_command.sh
次はdocker-compose.ymlにcommandを追加して先程追加したファイルをセットします。
commandはコンテナ起動時に呼ばれる処理になります。
services: app: container_name: my_container_app ports: - 3000:3000 build: . command: /sbin/init && ./docker_app_command.sh
ローカルでの更新を即時反映する
次はホスト側で編集したコードを即時反映されるようにします。
今は変更の度にImageの作り直しが必要なので時間がかかってしまいます。
コードの反映はdocker-syncというツールを使います。
ホスト側をマウントする形も考えたのですがパフォーマンスが良くないようなのでdocker-syncで進めていきたいと思います。
まずは下コマンドでdocker syncをインストールします。
gem install docker-sync
次にdocker-sync.ymlというファイルを作って下のように記述します。
version: "2" syncs: my_sync_volume: src: "." sync_excludes: - "log" - "tmp" - ".git"
docker-compose.ymlのservices → app → volumesとvolumesにdocker-sync.ymlで定義したVolumeを追加します。
services: app: volumes: - "my_sync_volume:/my_app:nocopy" volumes: my_volume: my_sync_volume: external: true
Docker Syncは下コマンドで動かします。
docker-sync start
docker-sync利用時ですが.docker-syncというフォルダが作られるので下のように.gitignoreに追加すると良いかと思います。
.docker-sync
同時にアプリケーションサーバーのDockerfileも修正します。
今までプロジェクト全体をCOPY . /my_appでコピーしていましたが下のように最小限にします。
そうする事で更新が最小限になってbundle install部分もレイヤーキャッシュを使ってくれるようになります。
COPY Gemfile /my_app/Gemfile COPY Gemfile.lock /my_app/Gemfile.lock RUN bundle install
コンテナ起動時にもbundle installをしてほしいのでdocker_app_command.shにbundle installを追加します。
#!/bin/sh bundle install rake db:create && rake db:migrate rake db:seed bin/rails s -b 0.0.0.0
これで無事にRailsを立ち上げられるようになりました。
その他利用したコマンド
キャッシュの全削除。
docker builder prune
キャッシュ利用状況の確認
docker system df
CentOS8へのインストール
Dockerをyumでインストールしたらうまくいかなかったので下コマンドでインストールと起動をしました。
dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo dnf -y install --nobest docker-ce docker-ce-cli systemctl enable docker systemctl start docker
Docker Composeのインストールは下コマンドで実施しました。
wget https://github.com/docker/compose/releases/download/1.25.5/docker-compose-Linux-x86_64 mv docker-compose-Linux-x86_64 /usr/local/bin/docker-compose chmod +x /usr/local/bin/docker-compose
参考URL
Docker入門(第一回)~Dockerとは何か、何が良いのか~ | さくらのナレッジ
【Docker】コンテナの停止と削除を同時に行う - (O+P)ut
PHPickerViewControllerで画像を取得する
PHPickerViewControllerというiOS14から登場したクラスで画像を取得してみました。
このクラスはUIImagePickerControllerと違って複数画像を選択することなども可能です。
まずは画面に画像選択用のボタンを設置します。
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let button = UIButton() button.setTitle("画像選択", for: .normal) button.setTitleColor(.black, for: .normal) button.addTarget(self, action:#selector(self.tapButton), for: .touchUpInside) button.sizeToFit() button.center = view.center view.addSubview(button) } @objc func tapButton() { } }
画面の真ん中にボタンを設置できました。
次はボタンを押すとPHPickerViewControllerが立ち上がるようにします。
画像選択可能数などの設定はPHPickerConfigurationを使って行っています。
import PhotosUI class ViewController: UIViewController { override func viewDidLoad() { // 省略 } @objc func tapButton() { var config = PHPickerConfiguration() config.filter = .images config.selectionLimit = 0 let pickerView = PHPickerViewController(configuration: config) present(pickerView, animated: true, completion: nil) } }
無事にPickerを表示できました。
最後にUIImageの取得処理を実装します。
画像の取得はPHPickerViewControllerDelegateで行います。
class ViewController: UIViewController { @objc func tapButton() { let pickerView = PHPickerViewController(configuration: config) pickerView.delegate = self // 追加 present(pickerView, animated: true, completion: nil) } } extension ViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true, completion: nil) results.forEach { $0.itemProvider.loadObject(ofClass: UIImage.self) { image, error in print(image) } } } }
ログを見ると正しく画像が選択できている事が分かります。
注意点ですがloadObjectは非同期の為、読み込み完了順に呼ばれます。
そのため画像選択の順番は保持されません。
もし順番を保持したい場合、保持するような仕組みを実装する必要があります。