この記事を書いた経緯
個人開発アプリで、ローカルに保存した画像ファイルの表示に AsyncImage
を使っていました。
ところが、画像のファイル名を変えずに FileManager.replaceItemAt(_:withItemAt:backupItemName:options:)
で対象ファイルの上書きを行ったところ、AsyncImage
の表示が更新されない事態に遭遇しました。
解決方法を見つけたので、今回はその解決方法を紹介します。
用意するもの
-
.id(_:)
モディファイア (iOS は 13 から利用可能 = すべての SwiftUI で使用可能) -
.id(_:)
モディファイアの引数に入れるIdentifiable
な変数 (String, Int, UUID など、なんでもよいです)
以上です。
コード例
ボタンを押したら参照元ファイルが書き換わる画面
再レンダリングがかからない例
import SwiftUI
struct SwiftUIView: View {
@State private var fileURL: URL = .init(string: "file://hoge/fuga/piyo")
var body: some View {
VStack {
AsyncImage(url: fileURL)
Button(
action: {
guard let fileURL = fileURL else { return }
let fileManager: FileManager = .default
// なにがしかのファイル上書き処理
try fileManager.replaceItemAt(fileURL, withItemAt: 新しいファイルのURL)
}
) {
Text("上書き保存")
}
}
}
}
これのボタンを押しても、AsyncImage
の url
に渡されているファイルの URL 自体は書き換わっていないので、再レンダリングがかかりません。
再レンダリングがかかってくれる例
import SwiftUI
struct SwiftUIView: View {
@State private var fileURL: URL = .init(string: "file://hoge/fuga/piyo")
// ↓ これを追加
@State private var currentRenderingImageID: Int = 0
var body: some View {
VStack {
AsyncImage(url: fileURL)
// ↓ これを追加
.id(currentRenderingImageID)
Button(
action: {
guard let fileURL = fileURL else { return }
let fileManager: FileManager = .default
// なにがしかのファイル上書き処理
try fileManager.replaceItemAt(fileURL, withItemAt: 新しいファイルのURL)
// ↓ これを追加
self.currentRenderingImageID = Int.random(0...10)
}
) {
Text("上書き保存")
}
}
}
}
こうすることで、AsyncImage に割り当てられた id
が変わるので、再描画がかかります。
Apple のドキュメントにも、こうあります
When the proxy value specified by the id parameter changes, the identity of the view — for example, its state — is reset.
注意点
id
モディファイアが付与された View は、id
が変わる度に再描画されるので、対象の View が @StateObject
など (?) の生成コストの高そう (?) なものを含んでいる場合、別の方法を検討したほうがよさそうです🤔
※尤も、その場合そもそも実装を見直した方が良い気がします。
再描画がかかってほしいコンポーネントは、大抵のケースの場合末端のコンポーネントだと思うので、末端のコンポーネントにはそのような生成コストの高いものを含めるべきでは無いと思います。
余談
ちなみに、.id()
に渡すものは本当になんでもよいです。
とりあえず 前の値 != 今の値
が true
になるようにしてやればよいわけです。
また、Core Data に画像の URL を保存していて、なおかつ取得したエンティティに UUID
や 最終更新日
などを持ったプロパティがある場合、こんな風にも書けます。
import SwiftUI
struct CoreDataImageViewerView: View {
@FetchRequest(
entity: Hoge.entity()
sortDescriptors: []
) var hoges: FetchedResults<Hoge>
List {
ForEach(hoges, id: \.id) { hoge in
let currentRenderingAsyncImageID: String = "\(hoge.id)\(hoge.updatedAt)"
AsyncImage(url: hoge.url)
.id(currentRenderingAsyncImageID)
}
}
}
updateAt
と UUID をつなぎ合わせた文字列を id
としtいます。
(UUID を混ぜないと不要な再レンダリングがかかりそうと勝手に思ったので混ぜました。なくても大丈夫なのかどうかは確認してませんごめんなさい😇)
こうすることで、大体のケースの場合 updatedAt
は更新のたびに変えると思うので、結果として更新後に AsyncImage の表示が変わってくれます。
最後に
いかがだったでしょうか。
個人的に、この挙動は React の memo
化されたコンポーネントの挙動と似ていて、再レンダリングがかかってほしいところでかからないときに <Fragment />
の key
に動的な値を突っ込む手法と似てるなーと思いました。