|
1 | 1 | import SwiftUI |
2 | 2 |
|
| 3 | +enum FluidInteractionVisuals { |
| 4 | + static let hoverScale: CGFloat = 1.01 |
| 5 | + static let pressedScale: CGFloat = 0.97 |
| 6 | + static let hoverAnimation: Animation = .spring(response: 0.18, dampingFraction: 0.78) |
| 7 | + static let pressedAnimation: Animation = .spring(response: 0.2, dampingFraction: 0.8) |
| 8 | + |
| 9 | + static func scale(isPressed: Bool, isHovered: Bool) -> CGFloat { |
| 10 | + if isPressed { return self.pressedScale } |
| 11 | + return isHovered ? self.hoverScale : 1 |
| 12 | + } |
| 13 | +} |
| 14 | + |
| 15 | +extension View { |
| 16 | + func fluidControlSurface( |
| 17 | + isSelected: Bool, |
| 18 | + isHovered: Bool, |
| 19 | + tone: Color, |
| 20 | + cornerRadius: CGFloat |
| 21 | + ) -> some View { |
| 22 | + self.modifier(FluidControlSurfaceModifier( |
| 23 | + isSelected: isSelected, |
| 24 | + isHovered: isHovered, |
| 25 | + tone: tone, |
| 26 | + cornerRadius: cornerRadius |
| 27 | + )) |
| 28 | + } |
| 29 | +} |
| 30 | + |
| 31 | +private struct FluidControlSurfaceModifier: ViewModifier { |
| 32 | + @Environment(\.theme) private var theme |
| 33 | + let isSelected: Bool |
| 34 | + let isHovered: Bool |
| 35 | + let tone: Color |
| 36 | + let cornerRadius: CGFloat |
| 37 | + |
| 38 | + func body(content: Content) -> some View { |
| 39 | + let shape = RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous) |
| 40 | + let fillOpacity = self.isSelected ? 0.96 : (self.isHovered ? 0.42 : 0) |
| 41 | + let shineOpacity = self.isSelected ? 0.14 : (self.isHovered ? 0.07 : 0) |
| 42 | + let strokeColor = self.isSelected |
| 43 | + ? self.tone.opacity(0.24) |
| 44 | + : (self.isHovered ? self.theme.palette.cardBorder.opacity(0.28) : .clear) |
| 45 | + |
| 46 | + content |
| 47 | + .background( |
| 48 | + shape |
| 49 | + .fill(self.theme.palette.cardBackground.opacity(fillOpacity)) |
| 50 | + .overlay( |
| 51 | + LinearGradient( |
| 52 | + colors: [.white.opacity(shineOpacity), .clear], |
| 53 | + startPoint: .topLeading, |
| 54 | + endPoint: .bottomTrailing |
| 55 | + ) |
| 56 | + .clipShape(shape) |
| 57 | + ) |
| 58 | + .overlay(shape.stroke(strokeColor, lineWidth: 1)) |
| 59 | + .shadow( |
| 60 | + color: .black.opacity(self.isSelected ? 0.16 : (self.isHovered ? 0.08 : 0)), |
| 61 | + radius: self.isSelected || self.isHovered ? 5 : 0, |
| 62 | + y: self.isSelected || self.isHovered ? 1 : 0 |
| 63 | + ) |
| 64 | + ) |
| 65 | + .scaleEffect(self.isHovered && !self.isSelected ? FluidInteractionVisuals.hoverScale : 1) |
| 66 | + .animation(FluidInteractionVisuals.hoverAnimation, value: self.isSelected) |
| 67 | + .animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered) |
| 68 | + } |
| 69 | +} |
| 70 | + |
3 | 71 | // MARK: - Primary (Prominent) Button |
4 | 72 |
|
5 | 73 | struct GlassButtonStyle: ButtonStyle { |
@@ -48,9 +116,9 @@ struct GlassButtonStyle: ButtonStyle { |
48 | 116 | x: 0, |
49 | 117 | y: self.isHovered ? self.theme.metrics.cardShadow.y : self.theme.metrics.cardShadow.y - 2 |
50 | 118 | ) |
51 | | - .scaleEffect(self.configuration.isPressed ? 0.97 : (self.isHovered ? 1.01 : 1.0)) |
52 | | - .animation(.spring(response: 0.18, dampingFraction: 0.78), value: self.isHovered) |
53 | | - .animation(.spring(response: 0.2, dampingFraction: 0.8), value: self.configuration.isPressed) |
| 119 | + .scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered)) |
| 120 | + .animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered) |
| 121 | + .animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed) |
54 | 122 | .onHover { self.isHovered = $0 } |
55 | 123 | } |
56 | 124 | } |
@@ -122,9 +190,9 @@ struct PremiumButtonStyle: ButtonStyle { |
122 | 190 | x: 0, |
123 | 191 | y: self.isHovered ? self.theme.metrics.elevatedCardShadow.y : self.theme.metrics.cardShadow.y |
124 | 192 | ) |
125 | | - .scaleEffect(self.configuration.isPressed ? 0.98 : (self.isHovered ? 1.01 : 1.0)) |
126 | | - .animation(.spring(response: 0.18, dampingFraction: 0.75), value: self.isHovered) |
127 | | - .animation(.spring(response: 0.18, dampingFraction: 0.75), value: self.configuration.isPressed) |
| 193 | + .scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered)) |
| 194 | + .animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered) |
| 195 | + .animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed) |
128 | 196 | .onHover { self.isHovered = $0 } |
129 | 197 | } |
130 | 198 | } |
@@ -169,9 +237,9 @@ struct SecondaryButtonStyle: ButtonStyle { |
169 | 237 | x: 0, |
170 | 238 | y: self.isHovered ? self.theme.metrics.cardShadow.y : self.theme.metrics.cardShadow.y - 2 |
171 | 239 | ) |
172 | | - .scaleEffect(self.configuration.isPressed ? 0.98 : (self.isHovered ? 1.01 : 1.0)) |
173 | | - .animation(.spring(response: 0.18, dampingFraction: 0.78), value: self.isHovered) |
174 | | - .animation(.spring(response: 0.2, dampingFraction: 0.8), value: self.configuration.isPressed) |
| 240 | + .scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered)) |
| 241 | + .animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered) |
| 242 | + .animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed) |
175 | 243 | .onHover { self.isHovered = $0 } |
176 | 244 | } |
177 | 245 | } |
@@ -231,9 +299,9 @@ struct CompactButtonStyle: ButtonStyle { |
231 | 299 | x: 0, |
232 | 300 | y: self.isHovered ? self.theme.metrics.cardShadow.y - 1 : 1 |
233 | 301 | ) |
234 | | - .scaleEffect(self.configuration.isPressed ? 0.97 : (self.isHovered ? 1.01 : 1.0)) |
235 | | - .animation(.spring(response: 0.18, dampingFraction: 0.78), value: self.isHovered) |
236 | | - .animation(.spring(response: 0.2, dampingFraction: 0.8), value: self.configuration.isPressed) |
| 302 | + .scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered)) |
| 303 | + .animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered) |
| 304 | + .animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed) |
237 | 305 | .onHover { self.isHovered = $0 } |
238 | 306 | } |
239 | 307 | } |
@@ -288,9 +356,9 @@ struct AccentButtonStyle: ButtonStyle { |
288 | 356 | x: 0, |
289 | 357 | y: self.isHovered ? 3 : 2 |
290 | 358 | ) |
291 | | - .scaleEffect(self.configuration.isPressed ? 0.97 : (self.isHovered ? 1.02 : 1.0)) |
292 | | - .animation(.spring(response: 0.18, dampingFraction: 0.78), value: self.isHovered) |
293 | | - .animation(.spring(response: 0.2, dampingFraction: 0.8), value: self.configuration.isPressed) |
| 359 | + .scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered)) |
| 360 | + .animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered) |
| 361 | + .animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed) |
294 | 362 | .onHover { self.isHovered = $0 } |
295 | 363 | } |
296 | 364 | } |
@@ -329,9 +397,9 @@ struct InlineButtonStyle: ButtonStyle { |
329 | 397 | x: 0, |
330 | 398 | y: self.isHovered ? 3 : 1 |
331 | 399 | ) |
332 | | - .scaleEffect(self.configuration.isPressed ? 0.96 : (self.isHovered ? 1.03 : 1.0)) |
333 | | - .animation(.easeOut(duration: 0.15), value: self.isHovered) |
334 | | - .animation(.easeOut(duration: 0.15), value: self.configuration.isPressed) |
| 400 | + .scaleEffect(FluidInteractionVisuals.scale(isPressed: self.configuration.isPressed, isHovered: self.isHovered)) |
| 401 | + .animation(FluidInteractionVisuals.hoverAnimation, value: self.isHovered) |
| 402 | + .animation(FluidInteractionVisuals.pressedAnimation, value: self.configuration.isPressed) |
335 | 403 | .onHover { self.isHovered = $0 } |
336 | 404 | } |
337 | 405 | } |
|
0 commit comments