概要
AppStore のアニメーションがカッコよかったので、SwiftUI で再現できないか試してみました。
できたこと
ここで ScrollView と言っているのは以下の青枠部分のことです:
できなかったこと
- セル(に当たる部分)上でスクロールすること
- ScrollView をスクロールした時に ScrollView 以外の View もスクロールさせること
- ScrollView を下にスワイプすると全画面表示をやめること
実装
セル(に当たる部分)に触れた時に少し縮むアニメーションをさせる
準備
まず、元となる一覧画面を作ります。ScrollView の中に、セルに当たる ContentView
を置きます。(今回は簡単のため、1つだけ置きます)
import SwiftUI
struct ContentView: View {
var body: some View {
ScrollView {
VStack(spacing: 30) {
Spacer()
Text("Today")
.font(.title)
.bold()
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
CardView()
}
}
}
}
ContentView
の実装は以下です。
struct CardView: View {
private let image: Image = Image(systemName: "star")
private let text: String = "test"
private var width: CGFloat {
UIScreen.main.bounds.width - 10
}
private var height: CGFloat {
300
}
var body: some View {
VStack {
image
.frame(maxWidth: .infinity)
Text(text)
.frame(maxWidth: .infinity)
}
.frame(width: width, height: height)
.background(Color.blue)
.cornerRadius(20)
}
}
アニメーション
ContentView
に触れると縮むアニメーションを追加します。
struct CardView: View {
@State private var scale: CGFloat = 1
// 中略
var body: some View {
let shrinkGesture = LongPressGesture(minimumDuration: 0.1)
.onChanged { _ in
withAnimation(.spring()) {
self.scale = 0.9
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.spring()) {
self.scale = 1
}
}
}
VStack {
// 中略
}
// 中略
.scaleEffect(scale)
.gesture(shrinkGesture)
}
}
ここで、
-
ScrollView
内ではLongPressGesture(minimumDuration: 0)
だとジェスチャーが効かなかったので、minimumDuration: 0.1
にした -
minimumDuration: 0
の時に.simultaneousGesture(shrinkGesture)
としてもジェスチャーが効かなかった
の挙動がありました。
セル(に当たる部分)をタップしたら画面全体に内容を表示させる
次に、CardView
をタップしたら画面全体を覆って内容を表示させるようにします。
準備
内容を表示する状態をshowDetailView = true
として、その状態の時に必要/不要なView
を表示/非表示させます(実際にリストっぽくする場合は考えていません)
struct ContentView: View {
@State private var showDetailView = false // 追加
var body: some View {
ScrollView {
VStack(spacing: 30) {
if showDetailView == false { // 追加
Spacer()
Text("Today")
.font(.title)
.bold()
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
CardView(showDetailView: $showDetailView) // CardView に showDetailView を渡す
}
}
}
}
struct CardView: View {
@Binding var showDetailView: Bool // showDetailView を受け取る
// 中略
var body: some View {
VStack {
if showDetailView {
Spacer()
.frame(height: 200)
} // 追加
Image(systemName: "star")
.frame(maxWidth: .infinity)
Text("test")
.frame(maxWidth: .infinity)
if showDetailView {
ScrollView {
VStack {
ForEach(1..<100) { _ in
Text("Can you scroll?")
.frame(maxWidth: .infinity)
}
}
}
} // 追加
}
// 中略
}
}
アニメーション
タップ時のアニメーションを追加し、frame
を変化させます。
struct CardView: View {
// 中略
private var width: CGFloat {
// showDetailView の時はスクリーンサイズにする
showDetailView ? UIScreen.main.bounds.width : UIScreen.main.bounds.width - 10
}
private var height: CGFloat {
// showDetailView の時はスクリーンサイズにする
showDetailView ? UIScreen.main.bounds.height : 300
}
var body: some View {
// 中略
let tapGesture = TapGesture()
.onEnded() {
withAnimation(.spring()) {
self.showDetailView = true
}
}
VStack {
// 中略
}
.cornerRadius(showDetailView ? 0 : 20) // 追加
// 中略
.gesture(shrinkGesture)
}
}
このままではsafeArea
部分は描画できないため、ContentView
のScrollView
に.ignoresSafeArea()
をつけます。
また、全画面表示にした時にも、下のScrollView
のスクロールが動いてしまうので、これもオフにします。(参考:StackOverflow)
struct ContentView: View {
// 中略
private var axes: Axis.Set {
showDetailView ? [] : .vertical
} // showDetailView の時にはスクロールさせない
var body: some View {
ScrollView(axes) {
// 中略
}
.ignoresSafeArea()
}
}
全画面表示の状態から、ScrollView ではない View を下にスワイプすると閉じる
最後に、CardView
のScrollView
より上にあるVStack
をスワイプすると、全画面表示から一覧画面に戻るようにします。
準備
struct CardView: View {
// 中略
var body: some View {
// 中略
VStack {
VStack { // VStack で Text までを囲う
if showDetailView {
Spacer()
.frame(height: 200)
}
Image(systemName: "star")
.frame(maxWidth: .infinity)
Text("test")
.frame(maxWidth: .infinity)
}
// 中略
}
}
アニメーション
struct CardView: View {
// 中略
var body: some View {
// 中略
// 下にドラッグすると showDetailView = false になるジェスチャーの追加
let dragGesture = DragGesture(minimumDistance: 0)
.onChanged { endedGesture in
let diff = 1 - endedGesture.translation.height / height
withAnimation(.spring()) {
if diff < 0.8 {
showDetailView = false
scale = 1
} else if diff < 1 {
scale = diff
}
}
}
.onEnded { _ in
withAnimation(.spring()) {
if scale < 0.8 {
showDetailView = false
}
scale = 1
}
}
VStack {
VStack {
// 中略
}
.contentShape(Rectangle()) // Spacer 領域もジェスチャー認識できるようにする
.gesture(showDetailView ? dragGesture : nil) // showDetailView == true の時のみ有効にする
if showDetailView {
ScrollView {
// 中略
}
}
}
// 中略
.gesture(showDetailView ? nil : tapGesture) // showDetailView == false の時のみ有効にする
.simultaneousGesture(showDetailView ? nil : shrinkGesture) // showDetailView == false の時のみ有効にする、何かがバッティングするようなので .simultaneousGesture にした
}
}
このままでは、CardView
の中のScrollView
とDragGesture
がバッティングするのか、途中でアニメーションが止まってしまう現象がありました。
そこで、.scaleEffect()
を使うのではなく、frame
自体を変化させるようにしました。そうすると、全画面表示から縮んで戻る時に左上を中心に縮んでしまうため、無理やりpadding
をつけました。
struct CardView: View {
// 中略
var body: some View {
// 中略
VStack {
// 中略
}
.frame(width: width * scale, height: height * scale) // frame を操作
.background(Color.blue)
.padding(.leading, showDetailView ? (width * (1 - scale)) / 2 : 0) // 全画面表示から戻る時に真ん中になるようにする
.padding(.top, showDetailView ? (height * (1 - scale)) / 2 : 0) // 全画面表示から戻る時に真ん中になるようにする
.cornerRadius(showDetailView ? 0 : 20)
.gesture(showDetailView ? nil : tapGesture)
.simultaneousGesture(showDetailView ? nil : shrinkGesture)
}
}
終わりに
SwiftUI の ScrollView はスクロール位置などの情報が取れないため、より凝ったアニメーションを作るには UIKit の ScrollView を使うしかなさそうだと思いました。