Last active
January 8, 2024 12:32
-
-
Save 1mash0/628e7328c9573d697ae3d3d3b2827c0c to your computer and use it in GitHub Desktop.
ColorタイルとHero Animation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
struct ContentView: View { | |
@State private var colors: [Color] = [ | |
.black, | |
.blue, | |
.brown, | |
.cyan, | |
.gray, | |
.green, | |
.indigo, | |
.mint, | |
.orange, | |
.pink, | |
.purple, | |
.red, | |
.teal, | |
.yellow | |
] | |
@State private var selectedColor: Color? | |
@State private var isShowing = false | |
@State private var isAnimation = false | |
@Namespace private var effect | |
var body: some View { | |
ZStack { | |
if let selectedColor, isShowing { | |
SelectedImage( | |
isShowing: $isShowing, | |
isAnimation: $isAnimation, | |
color: selectedColor, | |
imageEffect: effect | |
) | |
} else { | |
VStack { | |
Text("Colors") | |
.font(.headline) | |
colorsView | |
.opacity(isShowing ? 0.0 : 1.0) | |
Spacer() | |
} | |
} | |
} | |
} | |
private var colorsView: some View { | |
LazyVGrid( | |
columns: Array(repeating: .init(.flexible()), count: 3), | |
alignment: .center, | |
spacing: 20 | |
) { | |
ForEach(colors, id: \.self) { color in | |
color | |
.clipShape(RoundedRectangle(cornerRadius: 15)) | |
.overlay { | |
Text(color.description.capitalized) | |
.font(.headline) | |
.foregroundStyle(.white) | |
} | |
.matchedGeometryEffect(id: color, in: effect) | |
.frame(width: 80, height: 80) | |
.onTapGesture { | |
if !isAnimation { | |
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) { | |
selectedColor = color | |
isShowing.toggle() | |
isAnimation = true | |
} completion: { | |
isAnimation = false | |
} | |
} | |
} | |
} | |
} | |
.padding() | |
} | |
} | |
struct SelectedImage: View { | |
@State private var initOffset: CGFloat = .zero | |
@State private var offset: CGFloat = .zero | |
@State private var headerHeight: CGFloat = 200 | |
@State private var scale: CGFloat = 1.0 | |
@State private var blurRadius: CGFloat = .zero | |
@State private var isDetailShowing: Bool = false | |
let defaultHeaderHeight: CGFloat = 200 | |
let iconHeight: CGFloat = 200 | |
@Binding var isShowing: Bool | |
@Binding var isAnimation: Bool | |
let color: Color | |
let imageEffect: Namespace.ID | |
@Namespace private var detailEffect | |
var body: some View { | |
ZStack(alignment: .top) { | |
GeometryReader { proxy in | |
ScrollView { | |
ZStack { | |
LazyVStack(spacing: 0) { | |
ForEach(0..<50, id: \.self) { i in | |
Text("item \(i)") | |
.frame(maxWidth: .infinity, minHeight: 44) | |
.background(.white) | |
} | |
} | |
.padding(.top, defaultHeaderHeight) | |
GeometryReader { gr in | |
color | |
.clipShape(.rect(cornerRadius: 15)) | |
.frame(height: headerHeight) | |
.overlay { | |
VStack { | |
Text(color.description.capitalized) | |
.font(.title) | |
.foregroundStyle(.white) | |
} | |
} | |
.matchedGeometryEffect(id: color, in: imageEffect) | |
.blur(radius: blurRadius) | |
.onAppear { | |
initOffset = gr.frame(in: .global).origin.y | |
} | |
.offset(y: gr.frame(in: .global).origin.y < initOffset | |
? abs(gr.frame(in: .global).origin.y) | |
: initOffset - gr.frame(in: .global).origin.y) | |
.onChange(of: gr.frame(in: .global).origin.y, initial: true) { _, newValue in | |
offset = gr.frame(in: .global).minY | |
blurRadius = self.calcBlurRadius(maxRadius: 3.0, yOffset: offset) | |
headerHeight = self.calcHeaderHeight( | |
minHeight: defaultHeaderHeight / 2, | |
maxHeight: defaultHeaderHeight, | |
yOffset: offset | |
) | |
} | |
.onTapGesture { | |
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) { | |
isDetailShowing.toggle() | |
} | |
} | |
} | |
} | |
} | |
.opacity(isDetailShowing ? 0.0 : 1.0) | |
.ignoresSafeArea(edges: .vertical) | |
.background(.white) | |
if isDetailShowing { | |
color | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
.overlay { | |
VStack { | |
Text(color.description.capitalized) | |
.font(.largeTitle) | |
.foregroundStyle(.white) | |
} | |
} | |
.ignoresSafeArea() | |
} | |
HStack { | |
Button { | |
if isDetailShowing { | |
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) { | |
isDetailShowing.toggle() | |
} | |
} else { | |
withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) { | |
isShowing.toggle() | |
isAnimation = true | |
} completion: { | |
isAnimation = false | |
} | |
} | |
} label: { | |
Label("", systemImage: "chevron.backward") | |
.bold() | |
.foregroundStyle(.white) | |
} | |
Spacer() | |
} | |
.padding(.horizontal) | |
} | |
} | |
} | |
func calcHeaderHeight(minHeight: CGFloat, maxHeight: CGFloat, yOffset: CGFloat) -> CGFloat { | |
if maxHeight + yOffset < minHeight { | |
return minHeight | |
} | |
return maxHeight + yOffset | |
} | |
func calcBlurRadius(maxRadius: CGFloat, yOffset: CGFloat) -> CGFloat { | |
if yOffset - initOffset < 0 { | |
return 0.0 | |
} | |
return yOffset - initOffset < maxRadius ? yOffset - initOffset : maxRadius | |
} | |
func calcScale(minScale: CGFloat, maxScale: CGFloat, headerHeight: CGFloat) -> CGFloat { | |
if headerHeight == defaultHeaderHeight { | |
return maxScale | |
} | |
return headerHeight / defaultHeaderHeight < minScale ? minScale : headerHeight / defaultHeaderHeight | |
} | |
} | |
#Preview { | |
ContentView() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment