はじめに
こんにちは、アイカワと申します。
この記事は Qiita iOS Advent Calendar 2021 Calendar2 の 24 日目の記事になります。
前回の記事では、SwiftUI の基本的なアニメーションの実装方法について紹介しました。
本記事では、SwiftUI のアニメーションについて非同期なコードが絡む時にどのような工夫をしなければならないかということについて紹介します。特に、非同期なコードとして Combine を利用した時の話をしていきます。
前提知識として、combine-schedulers というライブラリの仕組みを知っていると記事が理解しやすくなると思うため、combine-schedulers についてキャッチアップするための記事等をぶら下げます。
- combine-schedulers
- https://github.com/pointfreeco/combine-schedulers
- Point-Free が提供するライブラリそのもの
- Point-Free が提供している combine-schedulers についての記事群
- (手前味噌ですが🙇♂️)自分が登壇した iOSDC の発表
Combine を使った時のアニメーションの実装方法
前回の記事では同期的な状態変更に伴うアニメーションについてのみ紹介しました。
同期的な状態変更に伴うアニメーションについては、animation(_:value:)
View Modifier や withAnimation(_:_:)
を利用すれば様々なアニメーションを意図通りに表現することが可能となっていました。
しかし、非同期な処理を Combine で実現している際には少し工夫しなければいけないことが出てきたりします。
次節以降では、SwiftUI+Combine でアニメーションを実現しようとした時に工夫しなければならないこととその辛さを説明し、その後で combine-schedulers の力を使って辛さを解消していく話をしていきます。
SwiftUI+Combine のみでの実装方法
前提となるコードの解説
まず素の SwiftUI+Combine を使ったアニメーションの実装方法について見ていくことにします。
ここで紹介する例は Point-Free さんが公開されている episode-code-samples/0137-swiftui-animation-pt3 というリポジトリのコードをもとにしています。
少しずつ見ていくために、まずはアニメーションも何もない簡単な List の View コードを示します。
import SwiftUI
struct ContentView: View {
@ObservedObject var viewModel: MoviesViewModel
var body: some View {
List {
ForEach(viewModel.movies) { movie in
HStack {
if movie.isFavorite {
Image(systemName: "heart.fill")
} else {
Image(systemName: "heart.fill")
.hidden()
}
Text(movie.name)
}
}
}
}
}
コードの通りですが、この View では MoviesViewModel
という ViewModel を利用して、List に ViewModel が保持する Movie の name
を Text で表示しています。
また、Movie の isFavorite
の状態に応じて、Text の横にハートマークを表示するかどうか決定しています。
MoviesViewModel
というものが出てきたので、そちらについても見ていきます。
// ① Movie struct
struct Movie: Identifiable, Equatable {
let id: UUID
let name: String
let isFavorite: Bool
}
// ② Movies ViewModel
final class MoviesViewModel: ObservableObject {
@Published var movies: [Movie] = []
init() {
allMovies()
.receive(on: DispatchQueue.main)
.assign(to: &self.$movies)
}
func allMovies() -> AnyPublisher<[Movie], Never> {
Just(
(1...20).map { index in
Movie(
id: UUID(),
name: "Movie \(index)",
isFavorite: index.isMultiple(of: 2)
)
}
)
.delay(for: 1, scheduler: DispatchQueue(label: "global queue")) // 擬似的な API 通信を表現
.eraseToAnyPublisher()
}
}
MoviesViewModel 内には Model を表現する struct と API 通信などの動きを表現する ViewModel が存在しています。
Movie struct は非常に単純な struct で、List で一意に Model を識別するために Identifiable
に準拠させています。また、後ほど animation(_:value:)
View Modifier の value で利用するために Equatable にも準拠させています。
MoviesViewModel は View に対して movies
を公開しています。View ではこれを subscribe することによって、movies が変更された際に自動的に View が更新されるようにしています。
MoviesViewModel 内の処理としては主に以下のようなことを行っています。
- initializer 内で、
allMovies
publisher をDispatchQueue.main
で receive しつつ subscribe して、流れてきた movies を ViewModel が保持する movies に入れている-
allMovies
は main queue 以外の queue で流れてくるため、UI の変更に関わるmovies
にassign
する以上 main queue で receive する必要がある
-
-
allMovies
は、API 通信を擬似的に表現するために1秒遅延させた上で20個の Movie を publish している
現時点では以下のような見た目の View が出来上がっている状態です。
暗黙的なアニメーションで List をアニメーションさせる
では、ここからこの List をアニメーションさせていきたいと思います。
前回の記事で紹介したように、アニメーションさせるためには暗黙的なアニメーション実装方法である animation(_:value:)
View Modifier を利用した方法と明示的なアニメーション実装方法である withAnimation(_:_:)
があるのでした。
まずは暗黙的なアニメーションを利用したコードを示します。
struct ContentView: View {
@ObservedObject var viewModel: MoviesViewModel
var body: some View {
List {
// ...
}
.animation(.default, value: viewModel.movies) // ここを追加しただけ
}
}
これで以下のようにアニメーションするようになります。
無事にアニメーションしていることがわかりますが、前回の記事でも説明したように状態が複雑になってくると animation(_:value:)
ではアニメーションを意図通りに制御することが難しい場面も出てきます。
そのため、withAnimation(_:_:)
を利用したアニメーションの実現方法についても見ていくことにします。
明示的なアニメーションで List をアニメーションさせる
今回、アニメーションに関係のある状態を変更している部分は以下のようなコードになっています。
final class MoviesViewModel: ObservableObject {
@Published var movies: [Movie] = []
init() {
allMovies()
.receive(on: DispatchQueue.main)
.assign(to: &self.$movies) // ここでアニメーションに関わる状態が変更される
}
// ...
}
コード内にもコメントで示しましたが、assign(to:)
の部分で View が利用している movies
という状態が変更されます。
そのため、この movies
を利用した View をアニメーションさせるためには、どうにかして movies
の変更を withAnimation(_:_:)
で囲む必要があります。
現状、assign(to:)
にはアニメーションさせるための機能などはないため、assign(to:)
を利用しつつ withAnimation(_:_:)
を利用する方法はおそらく存在しません。
そのため、withAnimation(_:_:)
を利用するために assign(to:)
ではなく sink
を利用するように変更してみます。
final class MoviesViewModel: ObservableObject {
@Published var movies: [Movie] = []
private var cancellables: Set<AnyCancellable> = []
init() {
allMovies()
.receive(on: DispatchQueue.main)
.sink { [weak self] movies in
withAnimation(.default) {
self?.movies = movies
}
}
.store(in: &cancellables)
}
// ...
}
一応上記のコードによって animation(_:value:)
を利用していた時と同じアニメーションを表現することができるようになります。
しかし assign(to:)
の代わりに sink
を利用したことによって、だいぶコードが冗長になってしまっていることがわかります。
具体的には以下のようなコードの変更が必要になっています。
-
sink
はAnyCancellable
を返却するため、明示的に管理する必要がある -
sink
内で retain cycle が起きないように weak self する必要がある
このように Combine が絡む処理で withAnimation(_:_:)
を利用しようとすると assign(to:)
というシンプルな API を捨てざるを得なくなってしまいます。
アニメーションのためにこの API を捨てなくてはいけないことだけでも工夫していく価値はありますが、もう少しだけ工夫していく価値を高めることになる例も見ていきます。
少し複雑な Combine の処理が絡む時のアニメーション制御方法
ここで、少し List に対して機能を追加します。
現在は適当な movie を20個ほど表示するだけのシンプルなアプリとなっていますが、このアプリに「キャッシュされたお気に入りの movie を先に List に表示し、今まで利用していた movie の擬似 fetch 処理が完了したら、既に List に表示されているお気に入りの movie に fetch された movie が追加される」という機能を追加します。
言葉だけだと何を言っているのかわかりにくいため、コードと動作を先に示します。
final class MoviesViewModel: ObservableObject {
@Published var movies: [Movie] = []
private var cancellables: Set<AnyCancellable> = []
init() {
allMovies()
.prepend(cachedFavoriteMovies())
.scan([], +)
.receive(on: DispatchQueue.main)
.sink { [weak self] movies in
withAnimation(.default) {
self?.movies = movies
}
}
.store(in: &cancellables)
}
func allMovies() -> AnyPublisher<[Movie], Never> {
Just(
(1...20).map { index in
Movie(
id: UUID(),
name: "Movie \(index)",
isFavorite: false
)
}
)
.delay(for: 2, scheduler: DispatchQueue(label: "global queue"))
.eraseToAnyPublisher()
}
func cachedFavoriteMovies() -> AnyPublisher<[Movie], Never> {
Just(
[
.init(id: .init(), name: "Favorite Movie 1", isFavorite: true),
.init(id: .init(), name: "Favorite Movie 2", isFavorite: true),
.init(id: .init(), name: "Favorite Movie 3", isFavorite: true),
]
)
.receive(on: DispatchQueue(label: "cache.queue"))
.eraseToAnyPublisher()
}
}
だいぶ変更点が多いので、少しずつ抜粋しながら説明していきます。
func cachedFavoriteMovies() -> AnyPublisher<[Movie], Never> {
Just(
[
.init(id: .init(), name: "Favorite Movie 1", isFavorite: true),
.init(id: .init(), name: "Favorite Movie 2", isFavorite: true),
.init(id: .init(), name: "Favorite Movie 3", isFavorite: true),
]
)
.receive(on: DispatchQueue(label: "cache.queue"))
.eraseToAnyPublisher()
}
こちらのコードは、お気に入りのキャッシュされた movie を表現するために3つほどの Movie を Just Publisher で放出しています。
DispatchQueue(label: "cache.queue")
で recive しているのは、通常キャッシュされたファイルを取得するためには時間がかかることが予想されるため、それを擬似的に表現しています。
init() {
allMovies()
.prepend(cachedFavoriteMovies())
.scan([], +)
.receive(on: DispatchQueue.main)
.sink { [weak self] movies in
withAnimation(.default) {
self?.movies = movies
}
}
.store(in: &cancellables)
}
initializer 部分もだいぶ変更されています。
ここでは具体的に以下のような処理を行っています。
-
prepend
operator によってallMovies
の fetch 前にcachedFavoriteMovies
Pubisher の結果を受け取っている- これによって先にキャッシュされた movie を表示して、後から fetch された movie を表示するという動作を表現できる
-
scan([], +)
operator によってcachedFavoriteMovies
の結果を保持しつつ、その結果にallMovies
の結果が加算されるようにしている-
scan
operator は Swift でいうreduce
のようなもの
-
後はほんの少しですが、以下の部分も変更しています。
func allMovies() -> AnyPublisher<[Movie], Never> {
Just(
(1...20).map { index in
Movie(
id: UUID(),
name: "Movie \(index)",
isFavorite: false // true から false に変更しただけ
)
}
)
.delay(for: 2, scheduler: DispatchQueue(label: "global queue"))
.eraseToAnyPublisher()
}
cachedFavoriteMovies
というお気に入り用の Publisher を用意したので、擬似的な fetch を表現している allMovies
ではお気に入り済みでない movie のみを放出するように変更しています。
ここまでの変更により、アプリの動作は以下のようなものになります。
コードの通り、キャッシュされたお気に入りの movie が先に3つ表示された後で、fetch された movie が表示されていることがわかります。
現在、キャッシュされたお気に入りの movie が表示される際と fetch された movie が表示される際両方で withAnimation(_:_:)
によるアニメーションが行われていますが、例えばお気に入りの movie が表示される際のみアニメーションさせたくないとしたらどのようなコードになるでしょうか?
Combine では、どの Publisher によって出力された movie かということを判別することが難しいため、これを実現するためには例えば区別するための状態を導入するという方法が考えられます。
init() {
var count = 0 // Publisher を区別するための変数を追加
allMovies()
.prepend(cachedFavoriteMovies())
.scan([], +)
.receive(on: DispatchQueue.main)
.sink { [weak self] movies in
count += 1
// count が 1 かどうかによってアニメーションを無効化するかどうか決定する
withAnimation(count == 1 ? nil : .default) {
self?.movies = movies
}
}
.store(in: &cancellables)
}
count
という変数を導入することによって、以下のようにキャッシュされた movie はアニメーションせず、fetch された movie のみアニメーションされるという動作を表現できます。
しかし、Publisher を区別するだけのために余計な状態を導入しなければならないのは非常に微妙です。
combine-schedulers を利用した実装方法
ここまでで説明してきたように、Combine を利用した時に withAnimation(_:_:)
を用いてアニメーションを行おうとすると以下のような問題が発生してしまっていました。
-
assign(to:)
というシンプルな API を捨ててsink
を利用しなければならない - 複数の Publisher を扱った時に、それぞれの Publisher を区別する方法がないため、Publisher ごとにアニメーションを制御することが難しい
この問題をどうにか解決する方法はないのでしょうか?
解決する方法を探るために、どうにかしてアニメーションを制御しようとしていた部分にブレークポイントを挟んでみます。
.sink { [weak self] movies in
withAnimation(.default) {
🔵 self?.movies = movies
}
}
すると以下のような stack trace が表示されます。
赤枠で囲んだ部分を見るとわかるように、main-thread や cache queue 上でこの処理が動作していることがわかります。
つまり、状態の変更に Scheduler が深く絡んでいるということですが、Combine の実装は公開されていないため、その処理が行われている部分を私たちが見ることは現実的には難しいです。
しかし、その処理が行われている部分を覗きやすくすることは可能です。
具体的には receive
が要求している Scheduler
protocol に準拠した独自の Scheduler を渡して同じようにブレークポイントを挟んでみるという方法が存在します。
ここで利用する Scheduler
protocol に準拠した独自の Scheduler は自分で作成することもできますが、既にそれを提供してくれている combine-schedulers を使うことにします。
Scheduler
protocol と combine-schedulers の詳細については、iOSDC の発表で詳しく説明しているため、この後の話が理解できなければそちらも参照して頂けたら嬉しいです。
では、早速 combine-schedulers の scheduler に置き換えてみます。
init(scheduler: AnySchedulerOf<DispatchQueue>) {
allMovies()
.prepend(cachedFavoriteMovies())
.scan([], +)
.receive(on: scheduler)
.sink { [weak self] movies in
withAnimation(.default) {
🔵 self?.movies = movies
}
}
.store(in: &cancellables)
}
この状態で先ほどと同じ部分にブレークポイントを挟んで実行してみると以下のような stack trace を見ることができます。
図にある stack trace の1については先ほどと同じです。
2について見てみると、combine-schedulers の AnyScheduler
が動いていて、さらに AnyScheduler
の schedule(options:_:)
が動いていることがわかります。
AnyScheduler
は Scheduler
protocol に準拠していますが、この protocol に準拠するためには以下の要件を満たす必要があります。
public protocol Scheduler {
associatedtype SchedulerTimeType : Strideable where Self.SchedulerTimeType.Stride : SchedulerTimeIntervalConvertible
associatedtype SchedulerOptions
// 2つのプロパティ
var now: Self.SchedulerTimeType { get }
var minimumTolerance: Self.SchedulerTimeType.Stride { get }
// 3つの function
func schedule(options: Self.SchedulerOptions?, _ action: @escaping () -> Void)
func schedule(after date: Self.SchedulerTimeType, tolerance: Self.SchedulerTimeType.Stride, options: Self.SchedulerOptions?, _ action: @escaping () -> Void)
func schedule(after date: Self.SchedulerTimeType, interval: Self.SchedulerTimeType.Stride, tolerance: Self.SchedulerTimeType.Stride, options: Self.SchedulerOptions?, _ action: @escaping () -> Void) -> Cancellable
}
要件の中の3つの function の1つ目に schedule(options:_:)
というものがあることがわかります。そして、先ほどブレークポイントを挟んだ時に動いていた function はこちらになります。
この function が引数に取る action
こそ、アニメーションさせる際に必要となる状態の変更を行っている部分となります。具体的には先ほどまでのコードで言うと以下の部分が action
として実行される部分になります。
withAnimation(.default) {
self?.movies = movies // これ
}
そのため、この function が扱う action
を withAnimation
で囲むことができれば無事 Scheduler 内からアニメーションを制御することができます。
ここまでの説明があれば後は combine-shedulers にある Scheduler
の extension として提供されている animation
function を示せば十分かなと思うため、そのコードを示します。
extension Scheduler {
public func animation(_ animation: Animation? = .default) -> AnySchedulerOf<Self> {
AnyScheduler(
minimumTolerance: { self.minimumTolerance },
now: { self.now },
scheduleImmediately: { options, action in
self.schedule(options: options) {
withAnimation(animation, action)
}
},
delayed: { date, tolerance, options, action in
self.schedule(after: date, tolerance: tolerance, options: options) {
withAnimation(animation, action)
}
},
interval: { date, interval, tolerance, options, action in
self.schedule(after: date, interval: interval, tolerance: tolerance, options: options) {
withAnimation(animation, action)
}
}
)
}
AnyScheduler
についての詳しい解説は省略しますが、上記のコードの通りで各 schedule function 内で withAnimation
を用いて action
を包んでいることがわかります。
そして、この animation
function は Scheduler
の extension として表現されており、さらにアニメーションの種類も指定できるような function になっているため、例えば以下のようにすれば利用することができます。
DispatchQueue.main.animation(.easeIn)
後はこの animation
function を用いて、先ほどの問題のコードを少しずつ修正してみます。一旦先ほどのコードを示します。
init() {
allMovies()
.prepend(cachedFavoriteMovies())
.scan([], +)
.receive(on: DispatchQueue.main)
.sink { [weak self] movies in
withAnimation(.default) {
self?.movies = movies
}
}
.store(in: &cancellables)
}
animation
function を用いれば、まずは以下のように書き換えることができます。
init() {
allMovies()
.prepend(cachedFavoriteMovies())
.scan([], +)
.receive(on: DispatchQueue.main.animation())
.sink { [weak self] movies in
self?.movies = movies
}
.store(in: &cancellables)
}
DispatchQueue.main.animation()
で receive している以降の処理は withAnimation
を利用している処理と同等の処理になるため、状態の変更部分を直接 withAnimation
で囲んでいた部分を削除できています。
そして、先ほど実現しようとしていた「キャッシュされた movie だけはアニメーションさせない」という動作は、以下のコードによって表現することが可能となります。
init() {
allMovies()
.receive(on: DispatchQueue.main.animation()) // allMovies は main.animation で receive する
.prepend(
cachedFavoriteMovies()
.receive(on: DispatchQueue.main) // cachedFavoriteMovies は main で receive する
)
.scan([], +)
.sink { [weak self] movies in
self?.movies = movies
}
.store(in: &cancellables)
}
そして、もはや上記のコードでは sink
内で状態の変更しか行っていないため、以下のように assign(to:)
を用いてスマートな形に修正することができます。
final class MoviesViewModel: ObservableObject {
@Published var movies: [Movie] = []
// private var cancellables: Set<AnyCancellable> = []
init() {
allMovies()
.receive(on: DispatchQueue.main.animation())
.prepend(
cachedFavoriteMovies()
.receive(on: DispatchQueue.main)
)
.scan([], +)
.assign(to: &self.$movies)
}
// ...
}
sink
を assign(to:)
に置き換えることができ、cancellables
も利用する必要がなくなったため、それも削除することができるようになります。
おわりに
本記事では、Combine が絡むアニメーション処理において、通常であればどのような問題が起きるかということについて説明しました。その後に combine-schedulers 内に付属している animation
function を利用するとその問題をスマートに解決できるという説明を行いました。
combine-schedulers の実装自体はそこまで厚いものではなく、あくまで Scheduler
protocol に準拠した独自の Scheduler を作り出しているというだけのものになっています。
ライブラリに依存してしまうという心配もあるかもしれませんが、combine-schedulers の仕組みを理解できればその心配も軽減されると個人的には思います。
この記事が combine-schedulers の導入に対する不安を軽減するものになれば幸いです。