Skip to content

Commit

Permalink
Animate gradient start and end points instead of stop locations
Browse files Browse the repository at this point in the history
  • Loading branch information
markiv committed Aug 21, 2023
1 parent 17b66b0 commit 804c4fc
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 77 deletions.
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# SwiftUI-Shimmer ✨

`Shimmer` is a super-light modifier that adds a shimmering effect to any SwiftUI `View`, for example, to show that an operation is in progress. It works well on light and dark modes, and across iOS, macOS, tvOS and watchOS.
`Shimmer` is a super-light modifier that adds a "shimmering" effect to any SwiftUI `View`, for example, to show that an operation is in progress. It works well on light and dark modes, left-to-right and right-to-left layout directions, and across all Apple platforms: iOS, macOS, tvOS, watchOS and even visionOS! 📱💻🖥️📺⌚️🥽✨

![visionOS](docs/Shimmer-visionOS.gif) ![watchOS](docs/Shimmer-watchOS.gif)


## Usage

```swift
import SwiftUI
Expand All @@ -20,16 +25,23 @@ Text("SwiftUI Shimmer").shimmering()

## Optional Parameters ⚙️

- `active`: Convenience parameter to conditionally enable the effect. Defaults to `true`.
- `animation`: A custom animation. Defaults to `Shimmer.defaultAnimation`.
- `gradient`: A custom gradient. Defaults to `Shimmer.defaultGradient`.
- `bandSize`: The size of the animated mask's "band". Defaults to 0.2 unit points, which corresponds to 20% of the extent of the gradient.

### Backward Compatible Parameters

- `active`: Convenience parameter to conditionally enable the effect. Defaults to `true`.
- `duration`: The duration of a shimmer cycle in seconds. Default: `1.5`.
- `bounce`: Whether to bounce (reverse) the animation back and forth. Defaults to `false`.
- `delay`: A delay in seconds. Defaults to `0`.

![Bounce 3](docs/bounce3.gif)

## Custom Animation
## Custom Animations

You can now provide a custom animation:
You can supply any custom animation:

```swift
Text("Loading...")
Expand Down
172 changes: 99 additions & 73 deletions Sources/Shimmer/Shimmer.swift
Original file line number Diff line number Diff line change
@@ -1,114 +1,131 @@
//
// Shimmer.swift
//
// SwiftUI-Shimmer
// Created by Vikram Kriplaney on 23.03.21.
//

import SwiftUI

/// A view modifier that applies an animated "shimmer" to any view, typically to show that
/// an operation is in progress.
/// A view modifier that applies an animated "shimmer" to any view, typically to show that an operation is in progress.
public struct Shimmer: ViewModifier {
let animation: Animation
@State private var phase: CGFloat = 0
private let animation: Animation
private let gradient: Gradient
private let min, max: CGFloat
@State private var isInitialState = true
@Environment(\.layoutDirection) private var layoutDirection

/// Initializes his modifier with a custom animation,
/// - Parameter animation: A custom animation. The default animation is
/// `.linear(duration: 1.5).repeatForever(autoreverses: false)`.
public init(animation: Animation = Self.defaultAnimation) {
/// - Parameters:
/// - animation: A custom animation. Defaults to ``Shimmer/defaultAnimation``.
/// - gradient: A custom gradient. Defaults to ``Shimmer/defaultGradient``.
/// - bandSize: The size of the animated mask's "band". Defaults to 0.2 unit points, which corresponds to
/// 20% of the extent of the gradient.
public init(
animation: Animation = Self.defaultAnimation,
gradient: Gradient = Self.defaultGradient,
bandSize: CGFloat = 0.2
) {
self.animation = animation
self.gradient = gradient
// Calculate unit point dimensions beyond the gradient's edges by the band size
self.min = 0 - bandSize
self.max = 1 + bandSize
}

/// The default animation effect.
public static let defaultAnimation = Animation.linear(duration: 1.5).repeatForever(autoreverses: false)
public static let defaultAnimation = Animation.linear(duration: 1.5).delay(0.25).repeatForever(autoreverses: false)

/// Convenience, backward-compatible initializer.
/// - Parameters:
/// - duration: The duration of a shimmer cycle in seconds. Default: `1.5`.
/// - bounce: Whether to bounce (reverse) the animation back and forth. Defaults to `false`.
/// - delay:A delay in seconds. Defaults to `0`.
public init(duration: Double = 1.5, bounce: Bool = false, delay: Double = 0) {
self.animation = .linear(duration: duration)
.repeatForever(autoreverses: bounce)
.delay(delay)
}

public func body(content: Content) -> some View {
content
.modifier(
AnimatedMask(phase: phase).animation(animation)
)
.onAppear { phase = 0.8 }
}
// A default gradient for the animated mask.
public static let defaultGradient = Gradient(colors: [
.black.opacity(0.3), // translucent
.black, // opaque
.black.opacity(0.3) // translucent
])

/// An animatable modifier to interpolate between `phase` values.
struct AnimatedMask: AnimatableModifier {
var phase: CGFloat = 0
/*
Calculating the gradient's animated start and end unit points:
min,min
\
┌───────┐ ┌───────┐
│0,0 │ Animate │ │ "forward" gradient
LTR │ │ ───────►│ 1,1│ / // /
└───────┘ └───────┘
\
max,max
max,min
/
┌───────┐ ┌───────┐
│ 1,0│ Animate │ │ "backward" gradient
RTL │ │ ───────►│0,1 │ \ \\ \
└───────┘ └───────┘
/
min,max
*/

var animatableData: CGFloat {
get { phase }
set { phase = newValue }
/// The start unit point of our gradient, adjusting for layout direction.
var startPoint: UnitPoint {
if layoutDirection == .rightToLeft {
return isInitialState ? UnitPoint(x: max, y: min) : UnitPoint(x: 0, y: 1)
} else {
return isInitialState ? UnitPoint(x: min, y: min) : UnitPoint(x: 1, y: 1)
}
}

func body(content: Content) -> some View {
content
.mask(GradientMask(phase: phase).scaleEffect(3))
/// The end unit point of our gradient, adjusting for layout direction.
var endPoint: UnitPoint {
if layoutDirection == .rightToLeft {
return isInitialState ? UnitPoint(x: 1, y: 0) : UnitPoint(x: min, y: max)
} else {
return isInitialState ? UnitPoint(x: 0, y: 0) : UnitPoint(x: max, y: max)
}
}

/// A slanted, animatable gradient between transparent and opaque to use as mask.
/// The `phase` parameter shifts the gradient, moving the opaque band.
struct GradientMask: View {
let phase: CGFloat
let centerColor = Color.black
let edgeColor = Color.black.opacity(0.3)
@Environment(\.layoutDirection) private var layoutDirection

var body: some View {
let isRightToLeft = layoutDirection == .rightToLeft
LinearGradient(
gradient: Gradient(stops: [
.init(color: edgeColor, location: phase),
.init(color: centerColor, location: phase + 0.1),
.init(color: edgeColor, location: phase + 0.2)
]),
startPoint: isRightToLeft ? .bottomTrailing : .topLeading,
endPoint: isRightToLeft ? .topLeading : .bottomTrailing
)
}
public func body(content: Content) -> some View {
content
.mask(LinearGradient(gradient: gradient, startPoint: startPoint, endPoint: endPoint))
.onAppear {
withAnimation(animation) {
isInitialState = false
}
}
}
}

public extension View {
/// Adds an animated shimmering effect to any view, typically to show that
/// an operation is in progress.
/// Adds an animated shimmering effect to any view, typically to show that an operation is in progress.
/// - Parameters:
/// - active: Convenience parameter to conditionally enable the effect. Defaults to `true`.
/// - duration: The duration of a shimmer cycle in seconds. Default: `1.5`.
/// - bounce: Whether to bounce (reverse) the animation back and forth. Defaults to `false`.
/// - delay:A delay in seconds. Defaults to `0`.
/// - animation: A custom animation. Defaults to ``Shimmer/defaultAnimation``.
/// - gradient: A custom gradient. Defaults to ``Shimmer/defaultGradient``.
/// - bandSize: The size of the animated mask's "band". Defaults to 0.2 unit points, which corresponds to
/// 20% of the extent of the gradient.
@ViewBuilder func shimmering(
active: Bool = true, duration: Double = 1.5, bounce: Bool = false, delay: Double = 0
active: Bool = true,
animation: Animation = Shimmer.defaultAnimation,
gradient: Gradient = Shimmer.defaultGradient,
bandSize: CGFloat = 0.2
) -> some View {
if active {
modifier(Shimmer(duration: duration, bounce: bounce, delay: delay))
modifier(Shimmer(animation: animation, gradient: gradient, bandSize: bandSize))
} else {
self
}
}

/// Adds an animated shimmering effect to any view, typically to show that
/// an operation is in progress.
/// Adds an animated shimmering effect to any view, typically to show that an operation is in progress.
/// - Parameters:
/// - active: Convenience parameter to conditionally enable the effect. Defaults to `true`.
/// - animation: A custom animation. The default animation is
/// `.linear(duration: 1.5).repeatForever(autoreverses: false)`.
@ViewBuilder func shimmering(active: Bool = true, animation: Animation = Shimmer.defaultAnimation) -> some View {
if active {
modifier(Shimmer(animation: animation))
} else {
self
}
/// - duration: The duration of a shimmer cycle in seconds.
/// - bounce: Whether to bounce (reverse) the animation back and forth. Defaults to `false`.
/// - delay:A delay in seconds. Defaults to `0.25`.\
@available(*, deprecated, message: "Use shimmering(active:animation:gradient:bandSize:) instead.")
@ViewBuilder func shimmering(
active: Bool = true, duration: Double, bounce: Bool = false, delay: Double = 0.25
) -> some View {
shimmering(
active: active,
animation: .linear(duration: duration).delay(delay).repeatForever(autoreverses: bounce)
)
}
}

Expand All @@ -130,6 +147,15 @@ struct Shimmer_Previews: PreviewProvider {
.padding()
.shimmering()
.previewLayout(.sizeThatFits)

VStack(alignment: .leading) {
Text("مرحبًا")
Text("← Right-to-left layout direction").font(.body)
Text("שלום")
}
.font(.largeTitle)
.shimmering()
.environment(\.layoutDirection, .rightToLeft)
}
}
#endif
2 changes: 1 addition & 1 deletion SwiftUI-Shimmer.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'SwiftUI-Shimmer'
s.version = '1.2.0'
s.version = '1.4.0'
s.summary = 'Shimmer is a super-light modifier that adds a shimmering effect to any SwiftUI View, for example, to show that an operation is in progress.'
s.homepage = 'https://github.com/markiv/SwiftUI-Shimmer'
s.license = { :type => 'MIT', :file => 'LICENSE' }
Expand Down
Binary file added docs/Shimmer-visionOS.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/Shimmer-watchOS.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 804c4fc

Please sign in to comment.