【SwiftUI】matchedGeometryEffectでできること
SwiftUI Advent Calendar 2022の10日目の記事です🎄
matchedGeometryEffect(id:in:properties:anchor:isSource:)
はSwiftUI 2.0から追加されたView修飾子です。iOS14.0から使用ができます。
今更感があるかもしれませんが、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付近から説明があります。
今回の記事では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
より初速を速くすることで、キビキビとした表現ができました。
NavigationStackとの併用
ナビゲーション遷移で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