12
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

iOSAdvent Calendar 2021

Day 24

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

Last updated at Posted at 2021-12-23

はじめに

こんにちは、アイカワと申します。
この記事は Qiita iOS Advent Calendar 2021 Calendar2 の 24 日目の記事になります。

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

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

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 の変更に関わる moviesassign する以上 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 を利用したことによって、だいぶコードが冗長になってしまっていることがわかります。
具体的には以下のようなコードの変更が必要になっています。

  • sinkAnyCancellable を返却するため、明示的に管理する必要がある
  • 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 の発表]
(https://fortee.jp/iosdc-japan-2021/proposal/8ee12d64-4eb0-4204-8f65-e0d981331b62)で詳しく説明しているため、この後の話が理解できなければそちらも参照して頂けたら嬉しいです。

では、早速 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 が動いていて、さらに AnySchedulerschedule(options:_:) が動いていることがわかります。

AnySchedulerScheduler 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 が扱う actionwithAnimation で囲むことができれば無事 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)
  }
  // ...
}

sinkassign(to:) に置き換えることができ、cancellables も利用する必要がなくなったため、それも削除することができるようになります。

おわりに

本記事では、Combine が絡むアニメーション処理において、通常であればどのような問題が起きるかということについて説明しました。その後に combine-schedulers 内に付属している animation function を利用するとその問題をスマートに解決できるという説明を行いました。

combine-schedulers の実装自体はそこまで厚いものではなく、あくまで Scheduler protocol に準拠した独自の Scheduler を作り出しているというだけのものになっています。
ライブラリに依存してしまうという心配もあるかもしれませんが、combine-schedulers の仕組みを理解できればその心配も軽減されると個人的には思います。

この記事が combine-schedulers の導入に対する不安を軽減するものになれば幸いです。

参照

12
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?