🍢

LazyVStackを使用すると描画処理が何度も走ってしまう

2022/01/31に公開

概要

iOS14.0で利用できるようになったLazyVStackにより、SwiftUIでの開発がやりやすくなったように感じます。
しかし私が携わっているプロジェクトにて少し複雑なレイアウトをLazyVStackを用いて組むと、スクロールがカクついてしまうような事象が発生しました。その一因として、LazyVStackの中にどのようなコンポーネントを配置するかによって毎回レンダリング処理が走ってしまうことが挙げられそうでした。
今回は自分がハマったコンポーネントの配置方法を紹介したいと思います。

Stack+ForEach

LazyVStackの中にVStack/HStack/ZStackを配置して、さらにその中でForEachを使うケースは多々あるのかなと思います。
例えば文字列を表示するシンプルなレイアウトを組んでみます。

再描画される書き方
struct ContentView: View {
  var body: some View {
    ScrollView {
      LazyVStack {
        VStack {
          ForEach(0..<10, id: \.self) { i in
            Text("Section1-\(i)")  // ここでブレークポイントを貼ると毎回停止する。
              .frame(height: 50)
          }
          .frame(maxWidth: .infinity)
          .background(.blue.opacity(0.2))
        }
        
        VStack {
          ForEach(10..<20, id: \.self) { i in
            Text("Section2-\(i)")
              .frame(height: 50)
          }
          .frame(maxWidth: .infinity)
          .background(.red.opacity(0.2))
        }
        
        VStack {
          ForEach(20..<30, id: \.self) { i in
            Text("Section3-\(i)")
              .frame(height: 50)
          }
          .frame(maxWidth: .infinity)
          .background(.green.opacity(0.2))
        }
        
        VStack {
          ForEach(30..<39, id: \.self) { i in
            Text("Section4-\(i)")
              .frame(height: 50)
          }
          .frame(maxWidth: .infinity)
          .background(.yellow.opacity(0.2))
        }
      }
    }
  }
}

この書き方で問題なのは以下のようにLazyVStackの配下にVStackを配置し、そのVStackスコープ内にForEachを配置しているところです。
この書き方をするとForEachスコープに配置したコンポーネントは毎回描画されてしまいます。
Text("Section1-\(i)")部分にブレークポイントを貼ると、画面に表示されるたびに停止します。

そこで以下のようにVStackを外すと初回のみ描画が行われ、その後何回スクロールしてもブレークポイントで停止することはありません。

再描画されない書き方
struct ContentView: View {
  var body: some View {
    ScrollView {
      LazyVStack {
        ForEach(0..<10, id: \.self) { i in
          Text("Section1-\(i)")
            .frame(height: 50)
        }
        .frame(maxWidth: .infinity)
        .background(.blue.opacity(0.2))
        
        ForEach(10..<20, id: \.self) { i in
          Text("Section2-\(i)")
            .frame(height: 50)
        }
        .frame(maxWidth: .infinity)
        .background(.red.opacity(0.2))
        
        ForEach(20..<30, id: \.self) { i in
          Text("Section3-\(i)")
            .frame(height: 50)
        }
        .frame(maxWidth: .infinity)
        .background(.green.opacity(0.2))
        
        ForEach(30..<39, id: \.self) { i in
          Text("Section4-\(i)")
            .frame(height: 50)
        }
        .frame(maxWidth: .infinity)
        .background(.yellow.opacity(0.2))
      }
    }
  }
}

Stack+SubView

次はLazyVStackの中にStackを配置して、更にその中にSubViewを配置するパターンです。
このパターンも先程と同様にText("SubView")の部分でブレークポイントを貼ると画面に表示されるたびに停止します。

再描画される書き方
struct ContentView: View {
  var body: some View {
    ScrollView {
      LazyVStack {
        Color.red
          .frame(maxWidth: .infinity)
          .frame(height: 1500)
        ZStack {
          SubView(content: "1-1")
        }
      }
    }
  }
}

struct SubView: View {
  var content: String
  var body: some View {
    Text("SubView") // ここでブレークポイントを貼ると毎回停止する。
  }
}

SubViewの中で複雑なレイアウトを組んでいる場合やNukeUI[AsyncImage]や(https://developer.apple.com/documentation/swiftui/asyncimage)を使っている場合、表示されるたびに毎回描画されるのでカクつきの原因になってしまう可能性がありそうです。

ただSubViewZStackなどのStackで囲わずにLazyVStack直下に配置すればSubViewは毎回描画されなくなります。

再描画されない書き方
struct ContentView2: View {
  var body: some View {
    ScrollView {
      LazyVStack {
        Color.red
          .frame(maxWidth: .infinity)
          .frame(height: 1500)
	// LazyVStack直下に配置するだけ  
        SubView(content: "1-1")
      }
    }
  }
}

TabView+ForEach

最後はTabViewForachを組み合わせるパターンです。
LazyVStackの中にpageスタイルのTabViewを入れその要素をForEachを用いて描画しようとすると、ForEachスコープ内のコンポーネントが表示されるたびに描画されてしまいます。

再描画される書き方
struct ContentView: View {
  private let images = [
    "https://via.placeholder.com/300x300",
    "https://via.placeholder.com/300x300",
    "https://via.placeholder.com/300x300",
    "https://via.placeholder.com/300x300",
  ]
  @State private var currentImageIndex = 0
  
  var body: some View {
    ScrollView {
      LazyVStack {
        TabView(selection: $currentImageIndex) {
          ForEach(images.indices, id: \.self) { index in
	    // ここでブレークポイントを貼ると毎回停止する。
            AsyncImage(url: URL(string: images[index])!) { image in
              image
            } placeholder: {
              ProgressView()
            }
          }
        }
        .frame(width: UIScreen.main.bounds.width, height: 300, alignment: .center)
        .tabViewStyle(.page(indexDisplayMode: .never))
        
        Color.red
          .frame(maxWidth: .infinity)
          .frame(height: 500)
        
        Color.blue
          .frame(maxWidth: .infinity)
          .frame(height: 500)
        
        Color.green
          .frame(maxWidth: .infinity)
          .frame(height: 500)
        
        Color.yellow
          .frame(maxWidth: .infinity)
          .frame(height: 500)
      }
    }
  }
}

ForEachを使用しなければ、AsyncImageは1度しか呼ばれなくなります。

再描画されない書き方
TabView(selection: $currentImageIndex) {
  AsyncImage(url: URL(string: images[index])!) { image in
    image
  } placeholder: {
    ProgressView()
  }
  AsyncImage(url: URL(string: images[index])!) { image in
    image
  } placeholder: {
    ProgressView()
  }
  // 省略...
}

まとめ

LazyVStackは便利そうに見えますが、コンポーネントの配置の仕方でパフォーマンスが悪くなってしまう場合があるように見えます。
同じようなレイアウトが繰り返されるデザインやリストのアイテムがシンプルなデザインの場合はLazyVStackで対応できるかもしれませんが、複雑なレイアウトが組み合わされたデザインやアイテムが複雑なデザインの場合は、LazyVStackを使っているとパフォーマンスが悪くなりカクつきの一因となってしまうかもしれません。
LazyVStackを使っていて「カクつくなー」と感じた場合は、今回紹介したものを疑ってみるのも良いかもしれません。
ただ私の認識やLazyVStackの使い方が間違っている可能性もあります。
このあたりについて見識があるかたはコメントいただけると幸いです。

なお私は複雑なデザインを組む場合はUIViewRepresentableUICollectionViewをラップして、UICollectionViewCellのViewはSwiftUIで組むようにしています。

GitHubで編集を提案

Discussion