💡

【SwiftUI】matchedGeometryEffectでできること

2022/12/10に公開

SwiftUI Advent Calendar 2022の10日目の記事です🎄

matchedGeometryEffect(id:in:properties:anchor:isSource:)はSwiftUI 2.0から追加されたView修飾子です。iOS14.0から使用ができます。

https://developer.apple.com/documentation/swiftui/view/matchedgeometryeffect(id:in:properties:anchor:issource:)

今更感があるかもしれませんが、AdventCalendarを機に活用方法を模索してみました。

matchedGeometryEffectは何を可能にする?

A, Bという別々に定義したViewがあるとします。
matchedGeometryEffectをAとBの両方に付与することで、AからB、(もしくは逆のBからA)の位置へシームレスにアニメーションすることができます。
※破線のViewは区別するために別途作ったものです

Playgroundsのサンプルコード
import SwiftUI
import PlaygroundSupport

struct ContentView: View {
  
  enum ViewPattern {
    case A
    case B
  }
  
  @Namespace private var namespace
  @State private var presentingView: ViewPattern = .A
  
  var body: some View {
    HStack(spacing: 16) {
      ZStack {
        if presentingView == .A {
          Rectangle()
            .matchedGeometryEffect(id: "rectangle", in: namespace) // matchさせたいもの同士で同じ id & namespace となるようにする
            .foregroundColor(.red)
            .frame(width: 100, height: 100)
            
        }
        // 補助用の破線
        Rectangle()
          .stroke(style: .init(dash: [4, 2]))
        Text("A")
      }
      .frame(width: 100, height: 100)
      
      ZStack {
        if presentingView == .B {
          Rectangle()
            .matchedGeometryEffect(id: "rectangle", in: namespace) // matchさせたいもの同士で同じ id & namespace となるようにする
            .foregroundColor(.green)
            .frame(width: 50, height: 50)
        }
        // 補助用の破線
        Rectangle()
          .stroke(style: .init(dash: [4, 2]))
          .frame(width: 50, height: 50)
        Text("B")
      }
      .frame(width: 100, height: 100)
    }
    .padding()
    .background()
    .onTapGesture {
      withAnimation(.easeOut(duration: 1)) {
        let next: ViewPattern = presentingView == .A ? .B : .A
        presentingView = next
      }
    }
  }
}

PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())

座標計算等を必要とせずViewを変化させることができ、UIの変更によって発生する視線の動きを補助できるメリットがあるかと思います。matchedGeometryEffectはあくまでViewの位置をリンクするだけで、どのようにレンダリングするかは別途transition(_:)で指定することができます。

Apple公式の使用例

WWDC2020ではMacOS BigSurで刷新されたコントロールセンターでも使用されていることが言及されていました。
また、シンプルなアルバムアプリを例に分かりやすく紹介されていたので、そちらを見るとより理解を深めることができるかと思います。

こちらの動画の00:18:30付近から説明があります。

https://developer.apple.com/videos/play/wwdc2020/10041/

今回の記事ではPlaygroundを使ったいくつかのデモとともに、matchedGeometryEffectの可能性を探ってみようと思います。若干ネタもありますがお付き合いください🙏

index

  • UISegmentedControlのような単一選択ができるUI
  • iOSのホーム画面のようなUI
  • NavigationStackとの併用
  • カードゲームのようなUI
  • サッカー日本代表がパス回しをするUI(!?)

UISegmentedControlのような単一選択ができるUIを作る

SwiftUIではPickerのPickerStyleを.segmentedとすれば、UISegmentedControlと同一のUIを表示できます。しかし、どちらにせよデザインの変更はあまり柔軟にできません。
そういった際にはmatchedGeometryEffectを活用して、Viewを作ってしまう方が早いかもしれません。

Playgroundsのサンプルコード
import SwiftUI
import PlaygroundSupport

struct Segment: Identifiable, Equatable {
  let id = UUID()
  let name: String
  
  init(name: String) {
    self.name = name
  }
}

struct SegmentedPickerView: View {
  
  @Namespace private var namespace
  @State private var selectedSegment = segments[0]
  
  private static let segments = [
    Segment(name: "A"),
    Segment(name: "B"),
    Segment(name: "C")
  ]
  
  var body: some View {
    HStack {
      ForEach(Self.segments) { segment in
        Button {
          withAnimation(.easeInOut) {
            selectedSegment = segment
          }
        } label: {
          Text(segment.name)
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        .matchedGeometryEffect(id: segment.id, in: namespace, isSource: true)
        .padding(2)
        .frame(width: 100, height: 30)
      }
    }
    .background(
      Capsule(style: .continuous)
        .matchedGeometryEffect(id: selectedSegment.id, in: namespace, isSource: false)
        .foregroundColor(.blue.opacity(0.2))
        .shadow(color: .black.opacity(0.2), radius: 2)
    )
    .background(Color(uiColor: .secondarySystemBackground))
    .clipShape(
      RoundedRectangle(cornerRadius: .infinity, style: .continuous)
    )
    .frame(width: 500, height: 500)
  }
}

PlaygroundPage.current.liveView = UIHostingController(rootView: SegmentedPickerView())

注意点

注意点はデフォルトではtrueのisSourceの扱いです。選択状態を表すCapsuleにはisSource: falseを設定することで、各ButtonをSourceとしてButtonの位置に移動するようになります。設定が抜けるとViewが荒ぶりますのでご注意ください。

Capsule(style: .continuous)
  .matchedGeometryEffect(
    id: selectedSegment.id,
    in: namespace, 
    isSource: false
  )

iOSのホーム画面のようなUI

サムネイル画像をタップしたら拡大して、プレビューを表示するアニメーションは活用例として多いかと思います。FlutterでいうHero Animationです。
今回はiOSのホーム画面に寄せたアニメーションをしてみました。

Playgroundsのサンプルコード
import SwiftUI
import PlaygroundSupport

struct App: Identifiable, Equatable {
  var id = UUID()
  var image: Image
  var name: String
}

struct ContentView: View {
  
  @Namespace private var namespace
  @State private var selectedApp: App? = nil
  
  private var apps = (1...16).map { index in
    App(image: Image(uiImage: #imageLiteral(resourceName: "image\(index).jpg")), name: "App \(index)")
  }
  
  private var columns = Array(repeating: GridItem(.fixed(50), spacing: 18), count: 4)
  
  var body: some View {
    ZStack {
      LazyVGrid(columns: self.columns) {
        ForEach(self.apps) { app in
          VStack(spacing: 2) {
            if selectedApp != app {
              app.image
                .resizable()
                .aspectRatio(1, contentMode: .fill)
                .cornerRadius(16)
                .onTapGesture {
                  withAnimation(.easeOutExpo) {
                    selectedApp = app
                  }
                }
                .matchedGeometryEffect(id: app.id, in: namespace)
                .frame(width: 50, height: 50)
                
            } else {
              Rectangle()
                .foregroundColor(.clear)
                .frame(width: 50, height: 50)
            }
            Text(app.name)
              .font(.caption2)
          }
        }
      }
      .padding()
      
      // preview
      if let app = selectedApp {
        app.image
          .resizable()
          .aspectRatio(1, contentMode: .fill)
          .cornerRadius(0)
          .onTapGesture {
            withAnimation(.easeOutExpo) {
              selectedApp = nil
            }
          }
          .matchedGeometryEffect(id: app.id, in: namespace)
          .frame(maxWidth: .infinity, maxHeight: .infinity)
      }
    }
    .frame(width: 300, height: 400)
  }
}

extension Animation {
  static let easeOutExpo: Animation = .timingCurve(0.25, 0.8, 0.1, 1, duration: 0.5) // 秘伝のタレ
}

PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())


ポイントは独自のAnimationである.easeOutExpoです。.easeOutより初速を速くすることで、キビキビとした表現ができました。

ナビゲーション遷移でHeroアニメーションを実現しようとしたのですが、試した限りNavigationStack/NavigationViewのアニメーションとコンフリクトするようで、残念ながらうまく機能させることができませんでした。ネット上においても相性が悪いというコメントを見かけたため、まだ併用は難しいかもしれません。

カードゲームのようなUI

カードゲームのようなViewの位置の変更が頻繁に起こるUIでも活用できそうです。
rotation3DEffect(_:axis:anchor:anchorZ:perspective:)と併用して、立体感のあるカードのレイアウトにしてみても面白いかもしれませんね

Playgroundsのサンプル

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
  
  @State var selectedCards: [Card] = [Card(icon: "🤣")]
  @State var unSelectedCards: [Card] = ["😀", "😅", "🥹", "😗", "😛"].map { Card(icon: $0) }
  @Namespace private var namespace
  
  var body: some View {
    VStack {
      // 場
      ZStack {
        RoundedRectangle(cornerRadius: 8)
          .stroke(.yellow, style: StrokeStyle(lineWidth: 2))
          .frame(width: 50, height: 80)
        ForEach(Array(selectedCards.enumerated()), id: \.element.id) { index, card in
          CardView(card: card)
            .matchedGeometryEffect(id: card.id, in: namespace)
            .transition(.scale(scale: 1)) // フェードを無効にする
        }
      }
      // 手札
      HStack(spacing: -20) {
        ForEach(Array(unSelectedCards.enumerated()), id: \.element.id) { index, card in
          CardView(card: card)
            .onTapGesture {
              withAnimation(.easeOut) {
                if let selected = selectedCards.first {
                  unSelectedCards[index] = selected
                }
                selectedCards.removeLast()
                selectedCards.append(card)
              }
            }
            .matchedGeometryEffect(id: card.id, in: namespace)
            .transition(.scale(scale: 1)) // フェードを無効にする
        }
      }
    }
    .padding()
    .frame(width: 500, height: 500)
    .background(.green)
  }
}

struct Card: Identifiable, Hashable {
  var icon: String
  var id = UUID()
}

struct CardView: View {
  var card: Card
  
  var body: some View {
    ZStack {
      RoundedRectangle(cornerRadius: 8)
        .foregroundColor(.white)
        .frame(width: 50, height: 80)
        .shadow(color: .black.opacity(0.2), radius: 2)
      Text(card.icon)
        .font(.title2)
    }
  }
}

PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())

サッカー日本代表がパス回しをするUI(!?)

ワールドカップを見ていたら唐突に思いついたので、突貫で作ってみました。ボールは常に一つなので、idは固定の"ball"としています。

Playgroundsのサンプル

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
  
  @State private var ballHolderNumber = 11
  @Namespace private var namespace
  
  var body: some View {
    VStack(spacing: 30) {
      HStack {
        PlayerView(
          playerNumber: 25,
          ballHolderNumber: $ballHolderNumber,
          namespace: namespace
        )
        
      }
      HStack(spacing: 50) {
        PlayerView(
          playerNumber: 11,
          ballHolderNumber: $ballHolderNumber,
          namespace: namespace
        )
        PlayerView(
          playerNumber: 15,
          ballHolderNumber: $ballHolderNumber,
          namespace: namespace
        )
      }
      HStack(spacing: 30) {
        PlayerView(
          playerNumber: 5,
          ballHolderNumber: $ballHolderNumber,
          namespace: namespace
        )
        PlayerView(
          playerNumber: 13,
          ballHolderNumber: $ballHolderNumber,
          namespace: namespace
        )
        PlayerView(
          playerNumber: 17,
          ballHolderNumber: $ballHolderNumber,
          namespace: namespace
        )
        PlayerView(
          playerNumber: 14,
          ballHolderNumber: $ballHolderNumber,
          namespace: namespace
        )
      }
      HStack(spacing: 30) {
        PlayerView(
          playerNumber: 4,
          ballHolderNumber: $ballHolderNumber,
          namespace: namespace
        )
        PlayerView(
          playerNumber: 22,
          ballHolderNumber: $ballHolderNumber,
          namespace: namespace
        )
        PlayerView(
          playerNumber: 3,
          ballHolderNumber: $ballHolderNumber,
          namespace: namespace
        )
      }
      HStack(spacing: 30) {
        PlayerView(
          playerNumber: 12,
          isKeeper: true,
          ballHolderNumber: $ballHolderNumber,
          namespace: namespace
        )
      }
    }
    .padding()
    .background(
      VStack(spacing: 0) {
        ForEach([Int](1...5), id: \.self) { _ in
          Rectangle()
            .foregroundColor(.grassGreen)
          Rectangle()
            .foregroundColor(.grassDeepGreen)
        }
      }
    )
  }
}

struct PlayerView: View {
  
  var playerNumber: Int
  var isKeeper = false
  @Binding var ballHolderNumber: Int
  var namespace: Namespace.ID
  
  var body: some View {
    VStack {
      ZStack {
        Image(uiImage: isKeeper ? #imageLiteral(resourceName: "uniform2.png") : #imageLiteral(resourceName: "uniform.png"))
          .resizable()
          .aspectRatio(contentMode: .fit)
          .frame(width: 50)
        Text(String(playerNumber))
          .foregroundColor(isKeeper ? .white : .yellow)
      }
      .onTapGesture {
        withAnimation {
          ballHolderNumber = playerNumber
        }
      }
      if ballHolderNumber == playerNumber {
        Text("⚽️")
          .matchedGeometryEffect(id: "ball", in: namespace)
      }
    }
    .frame(height: 80)
  }
}

extension Color {
  static let grassGreen = Color(
    red: 42.0 / 255.0,
    green: 147.0 / 255.0,
    blue: 71.0 / 255.0
  )
  static let grassDeepGreen = Color(
    red: 8.0 / 255.0,
    green: 123.0 / 255.0,
    blue: 40.0 / 255.0
  )
}

PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())

まとめ

matchedGeometryEffectは座標位置の計算をせずともシームレスなアニメーションを実現してくれる便利なAPIでした。今回試したこと以外においても、活用できるケースは色々とありそうです。
他にもこんなことに使っているなどありましたら、コメントにて教えていただけますと幸いです。

Discussion