Created
August 25, 2023 06:51
-
-
Save nathantannar4/f25e52cfbc6e92c95f54a8867ce21e62 to your computer and use it in GitHub Desktop.
SwiftUI ChangeEffect ViewModifier
This file contains hidden or 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 var counter: Int = 0 | |
var body: some View { | |
VStack(spacing: 24) { | |
Image(systemName: "exclamationmark.triangle.fill") | |
.font(Font.system(size: 50)) | |
.changeEffect(.shake, value: counter, animation: .spring(response: 0.25, dampingFraction: 0.1)) | |
Image(systemName: "exclamationmark.triangle.fill") | |
.font(Font.system(size: 50)) | |
.changeEffect( | |
.pulse { | |
RoundedRectangle(cornerRadius: 5) | |
.stroke(lineWidth: 2) | |
}, | |
value: counter, | |
animation: .asymmetric(insertion: .easeIn, removal: .easeOut(duration: 1)) | |
) | |
.containerShape(RoundedRectangle(cornerRadius: 5)) | |
.foregroundColor(.yellow) | |
Button("Run") { | |
counter += 1 | |
} | |
.buttonStyle(.bordered) | |
} | |
} | |
} | |
extension View { | |
public func changeEffect< | |
Effect: ChangeEffect, | |
Value: Equatable | |
>( | |
_ effect: Effect, | |
value: Value, | |
animation: Animation, | |
isEnabled: Bool = true | |
) -> some View { | |
changeEffect( | |
effect, | |
value: value, | |
animation: .continuous(animation), | |
isEnabled: isEnabled | |
) | |
} | |
public func changeEffect< | |
Effect: ChangeEffect, | |
Value: Equatable | |
>( | |
_ effect: Effect, | |
value: Value, | |
animation: ChangeEffectAnimation = .default, | |
isEnabled: Bool = true | |
) -> some View { | |
modifier( | |
ChangeEffectModifier( | |
effect: effect, | |
value: value, | |
animation: animation, | |
isEnabled: isEnabled | |
) | |
) | |
} | |
} | |
public struct ChangeEffectAnimation { | |
var insertion: Animation | |
var removal: Animation | |
public static let `default`: ChangeEffectAnimation = .continuous(.linear(duration: 0.35)) | |
public static func continuous( | |
_ animation: Animation | |
) -> ChangeEffectAnimation { | |
ChangeEffectAnimation( | |
insertion: animation.speed(2), | |
removal: animation.speed(2) | |
) | |
} | |
public static func asymmetric( | |
insertion: Animation, | |
removal: Animation | |
) -> ChangeEffectAnimation { | |
ChangeEffectAnimation( | |
insertion: insertion, | |
removal: removal | |
) | |
} | |
} | |
public struct ChangeEffectModifier< | |
Effect: ChangeEffect, | |
Value: Equatable | |
>: ViewModifier, Animatable { | |
var effect: Effect | |
var value: Value | |
var animation: ChangeEffectAnimation | |
var isEnabled: Bool | |
@State var id: UInt = 0 | |
@State var isActive = false | |
public init( | |
effect: Effect, | |
value: Value, | |
animation: Animation, | |
isEnabled: Bool = true | |
) { | |
self.init( | |
effect: effect, | |
value: value, | |
animation: .continuous(animation), | |
isEnabled: isEnabled | |
) | |
} | |
public init( | |
effect: Effect, | |
value: Value, | |
animation: ChangeEffectAnimation = .default, | |
isEnabled: Bool = true | |
) { | |
self.effect = effect | |
self.value = value | |
self.animation = animation | |
self.isEnabled = isEnabled | |
} | |
public func body(content: Content) -> some View { | |
content | |
.modifier( | |
ChangeEffectModifierBody( | |
effect: effect, | |
isActive: $isActive, | |
id: id | |
) | |
.animation(isActive ? animation.insertion : animation.removal) | |
) | |
.onChange(of: value) { _ in | |
if isEnabled { | |
id = id &+ 1 | |
isActive = true | |
} | |
} | |
} | |
} | |
struct ChangeEffectModifierBody< | |
Effect: ChangeEffect | |
>: ViewModifier, Animatable { | |
var effect: Effect | |
var isActive: Binding<Bool> | |
var id: UInt | |
var animatableData: Double | |
init( | |
effect: Effect, | |
isActive: Binding<Bool>, | |
id: UInt | |
) { | |
self.effect = effect | |
self.isActive = isActive | |
self.id = id | |
self.animatableData = isActive.wrappedValue ? 1 : 0 | |
} | |
func body(content: Content) -> some View { | |
content | |
.modifier( | |
effect.makeEffect( | |
configuration: ChangeEffectConfiguration( | |
id: ChangeEffectID(value: id), | |
isActive: isActive.wrappedValue, | |
progress: animatableData | |
) | |
) | |
.transaction { $0.disablesAnimations = true } | |
) | |
.onChange(of: animatableData >= 1) { isComplete in | |
if isComplete { | |
isActive.wrappedValue = false | |
} | |
} | |
} | |
} | |
public protocol ChangeEffect { | |
associatedtype Modifier: ViewModifier | |
@MainActor func makeEffect(configuration: Configuration) -> Modifier | |
typealias Configuration = ChangeEffectConfiguration | |
} | |
public struct ChangeEffectID: Hashable { | |
var value: UInt | |
} | |
public struct ChangeEffectConfiguration { | |
public let id: ChangeEffectID | |
public let isActive: Bool | |
public let progress: Double | |
} | |
extension ChangeEffect where Self == ShakeEffect { | |
public static var shake: ShakeEffect { .init() } | |
} | |
public struct ShakeEffect: ChangeEffect { | |
public func makeEffect(configuration: Configuration) -> Modifier { | |
Modifier(configuration: configuration) | |
} | |
public struct Modifier: ViewModifier { | |
var configuration: Configuration | |
public func body(content: Content) -> some View { | |
content | |
.offset(x: configuration.progress * 25, y: 0) | |
} | |
} | |
} | |
extension ChangeEffect { | |
public static func pulse<S: View>( | |
@ViewBuilder shape: () -> S | |
) -> PulseEffect<S> where Self == PulseEffect<S> { | |
PulseEffect(shape: shape()) | |
} | |
} | |
public struct PulseEffect<S: View>: ChangeEffect { | |
public var shape: S | |
public init(shape: S) { | |
self.shape = shape | |
} | |
public func makeEffect(configuration: Configuration) -> Modifier { | |
Modifier(configuration: configuration, shape: shape) | |
} | |
public struct Modifier: ViewModifier { | |
var configuration: Configuration | |
var shape: S | |
public func body(content: Content) -> some View { | |
content | |
.overlay { | |
if configuration.isActive { | |
shape | |
.transition( | |
.asymmetric( | |
insertion: .scale(scale: 0.1), | |
removal: .scale(scale: 1.5) | |
) | |
.combined(with: .opacity) | |
) | |
.id(configuration.id) | |
} | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment