Combine を使ったアニメーションを Scheduler で自由自在に操る方法

# はじめに
こんにちは、[アイカワ](https://twitter.com/kalupas226)と申します。
この記事は [Qiita iOS Advent Calendar 2021 Calendar2](https://qiita.com/advent-calendar/2021/ios) の 24 日目の記事になります。

[前回の記事](https://qiita.com/kalupas226/items/153fdaf783379e7d865a)では、SwiftUI の基本的なアニメーションの実装方法について紹介しました。
本記事では、SwiftUI のアニメーションについて非同期なコードが絡む時にどのような工夫をしなければならないかということについて紹介します。特に、非同期なコードとして Combine を利用した時の話をしていきます。

前提知識として、combine-schedulers というライブラリの仕組みを知っていると記事が理解しやすくなると思うため、combine-schedulers についてキャッチアップするための記事等をぶら下げます。

- combine-schedulers
  - https://github.com/pointfreeco/combine-schedulers
  - Point-Free が提供するライブラリそのもの
- Point-Free が提供している combine-schedulers についての記事群
  - https://www.pointfree.co/collections/combine/schedulers
- （手前味噌ですが🙇‍♂️）自分が登壇した iOSDC の発表
  - https://fortee.jp/iosdc-japan-2021/proposal/8ee12d64-4eb0-4204-8f65-e0d981331b62

# Combine を使った時のアニメーションの実装方法
前回の記事では同期的な状態変更に伴うアニメーションについてのみ紹介しました。
同期的な状態変更に伴うアニメーションについては、`animation(_:value:)` View Modifier や `withAnimation(_:_:)` を利用すれば様々なアニメーションを意図通りに表現することが可能となっていました。

しかし、非同期な処理を Combine で実現している際には少し工夫しなければいけないことが出てきたりします。
次節以降では、SwiftUI+Combine でアニメーションを実現しようとした時に工夫しなければならないこととその辛さを説明し、その後で combine-schedulers の力を使って辛さを解消していく話をしていきます。

## SwiftUI+Combine のみでの実装方法
### 前提となるコードの解説
まず素の SwiftUI+Combine を使ったアニメーションの実装方法について見ていくことにします。
ここで紹介する例は Point-Free さんが公開されている [episode-code-samples/0137-swiftui-animation-pt3](https://github.com/pointfreeco/episode-code-samples/tree/main/0137-swiftui-animation-pt3) というリポジトリのコードをもとにしています。

少しずつ見ていくために、まずはアニメーションも何もない簡単な List の View コードを示します。

```swift
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` というものが出てきたので、そちらについても見ていきます。

```swift
// ① 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 が出来上がっている状態です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182606/a028610c-64df-352d-fb56-3331729916a7.png" width=300>

### 暗黙的なアニメーションで List をアニメーションさせる
では、ここからこの List をアニメーションさせていきたいと思います。
前回の記事で紹介したように、アニメーションさせるためには暗黙的なアニメーション実装方法である `animation(_:value:)` View Modifier を利用した方法と明示的なアニメーション実装方法である `withAnimation(_:_:)` があるのでした。

まずは暗黙的なアニメーションを利用したコードを示します。

```swift
struct ContentView: View {
  @ObservedObject var viewModel: MoviesViewModel
  
  var body: some View {
    List {
      // ...
    }
    .animation(.default, value: viewModel.movies) // ここを追加しただけ
  }
}
```

これで以下のようにアニメーションするようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182606/5c63e3fa-60e4-7583-f359-62c35899b081.gif" width=300>


無事にアニメーションしていることがわかりますが、前回の記事でも説明したように状態が複雑になってくると `animation(_:value:)` ではアニメーションを意図通りに制御することが難しい場面も出てきます。

そのため、`withAnimation(_:_:)` を利用したアニメーションの実現方法についても見ていくことにします。

### 明示的なアニメーションで List をアニメーションさせる
今回、アニメーションに関係のある状態を変更している部分は以下のようなコードになっています。

```swift
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` を利用するように変更してみます。

```swift
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 が追加される」という機能を追加します。

言葉だけだと何を言っているのかわかりにくいため、コードと動作を先に示します。

```swift
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()
  }
}
```

だいぶ変更点が多いので、少しずつ抜粋しながら説明していきます。

```swift
  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 しているのは、通常キャッシュされたファイルを取得するためには時間がかかることが予想されるため、それを擬似的に表現しています。

```swift
  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` のようなもの

後はほんの少しですが、以下の部分も変更しています。

```swift
  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 のみを放出するように変更しています。

ここまでの変更により、アプリの動作は以下のようなものになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182606/2dca08e9-2c90-d99a-947c-d9dc8a218e53.gif" width=300>!

コードの通り、キャッシュされたお気に入りの movie が先に3つ表示された後で、fetch された movie が表示されていることがわかります。

現在、キャッシュされたお気に入りの movie が表示される際と fetch された movie が表示される際両方で `withAnimation(_:_:)` によるアニメーションが行われていますが、例えばお気に入りの movie が表示される際のみアニメーションさせたくないとしたらどのようなコードになるでしょうか？

Combine では、どの Publisher によって出力された movie かということを判別することが難しいため、これを実現するためには例えば区別するための状態を導入するという方法が考えられます。

```swift
  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 のみアニメーションされるという動作を表現できます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182606/5c0c3631-8fe4-ab43-d058-9258ee259ce5.gif" width=300>

しかし、Publisher を区別するだけのために余計な状態を導入しなければならないのは非常に微妙です。

## combine-schedulers を利用した実装方法
ここまでで説明してきたように、Combine を利用した時に `withAnimation(_:_:)` を用いてアニメーションを行おうとすると以下のような問題が発生してしまっていました。

- `assign(to:)` というシンプルな API を捨てて `sink` を利用しなければならない
- 複数の Publisher を扱った時に、それぞれの Publisher を区別する方法がないため、Publisher ごとにアニメーションを制御することが難しい

この問題をどうにか解決する方法はないのでしょうか？

解決する方法を探るために、どうにかしてアニメーションを制御しようとしていた部分にブレークポイントを挟んでみます。

```swift
.sink { [weak self] movies in
  withAnimation(.default) {
  🔵  self?.movies = movies
  }
}
```

すると以下のような　stack trace が表示されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182606/11509b8c-ba4e-c100-b973-97407f7ad266.png" width=400>

赤枠で囲んだ部分を見るとわかるように、main-thread や cache queue 上でこの処理が動作していることがわかります。
つまり、状態の変更に Scheduler が深く絡んでいるということですが、Combine の実装は公開されていないため、その処理が行われている部分を私たちが見ることは現実的には難しいです。

しかし、その処理が行われている部分を覗きやすくすることは可能です。
具体的には `receive` が要求している `Scheduler` protocol に準拠した独自の Scheduler を渡して同じようにブレークポイントを挟んでみるという方法が存在します。
ここで利用する `Scheduler` protocol に準拠した独自の Scheduler は自分で作成することもできますが、既にそれを提供してくれている [combine-schedulers](https://github.com/pointfreeco/combine-schedulers) を使うことにします。

`Scheduler` protocol と combine-schedulers の詳細については、[iOSDC の発表]
(https://fortee.jp/iosdc-japan-2021/proposal/8ee12d64-4eb0-4204-8f65-e0d981331b62)で詳しく説明しているため、この後の話が理解できなければそちらも参照して頂けたら嬉しいです。

では、早速 combine-schedulers の scheduler に置き換えてみます。

```swift
  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 を見ることができます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/182606/a5cca8a3-fcef-f964-bd3c-c74109338905.png" width=400>

図にある stack trace の1については先ほどと同じです。
2について見てみると、combine-schedulers の `AnyScheduler` が動いていて、さらに `AnyScheduler` の `schedule(options:_:)` が動いていることがわかります。

`AnyScheduler` は `Scheduler` protocol に準拠していますが、この protocol に準拠するためには以下の要件を満たす必要があります。

```swift
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` として実行される部分になります。

```swift
withAnimation(.default) {
  self?.movies = movies // これ
}
```

そのため、この function が扱う `action` を `withAnimation` で囲むことができれば無事 Scheduler 内からアニメーションを制御することができます。

ここまでの説明があれば後は combine-shedulers にある `Scheduler` の extension として提供されている `animation` function を示せば十分かなと思うため、そのコードを示します。

```swift
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 になっているため、例えば以下のようにすれば利用することができます。

```swift
DispatchQueue.main.animation(.easeIn)
```

後はこの `animation` function を用いて、先ほどの問題のコードを少しずつ修正してみます。一旦先ほどのコードを示します。

```swift
  init() {
    allMovies()
      .prepend(cachedFavoriteMovies())
      .scan([], +)
      .receive(on: DispatchQueue.main)
      .sink { [weak self] movies in
        withAnimation(.default) {
          self?.movies = movies
        }
      }
      .store(in: &cancellables)
  }
```

`animation` function を用いれば、まずは以下のように書き換えることができます。

```swift
  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 だけはアニメーションさせない」という動作は、以下のコードによって表現することが可能となります。

```swift
  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:)` を用いてスマートな形に修正することができます。

```swift
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 の導入に対する不安を軽減するものになれば幸いです。

# 参照
- [combine-schedulers](https://github.com/pointfreeco/combine-schedulers)
- [SwiftUI Animation: Composable Architecture](https://www.pointfree.co/episodes/ep136-swiftui-animation-the-basics)
- [SwiftUI Animation: The Point](https://www.pointfree.co/episodes/ep137-swiftui-animation-the-point)