Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add shimmer and skeletonLoading view modifier #54

Merged
merged 19 commits into from
Feb 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ SpeziViews contributors
* [Vishnu Ravi](https://github.com/vishnuravi)
* [Andreas Bauer](https://github.com/Supereg)
* [Philipp Zagar](https://github.com/philippzagar)
* [Nikolai Madlener](https://github.com/nikolaimadlener)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
This source file is part of the Stanford Spezi open-source project

SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md)

SPDX-License-Identifier: MIT
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
This source file is part of the Stanford Spezi open-source project

SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md)

SPDX-License-Identifier: MIT
5 changes: 5 additions & 0 deletions Sources/SpeziViews/SpeziViews.docc/SpeziViews.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ Default layouts and utilities to automatically adapt your view layouts to dynami
- ``SwiftUICore/View/if(_:transform:)``
- ``SwiftUICore/View/if(condition:transform:)``

### Animations and Visual Effects

- ``SwiftUICore/View/shimmer(repeatInterval:)``
- ``SwiftUICore/View/skeletonLoading(replicationCount:repeatInterval:spacing:)``

### Interact with the View Environment

- ``SwiftUICore/View/focusOnTap()``
Expand Down
60 changes: 60 additions & 0 deletions Sources/SpeziViews/ViewModifier/ShimmerModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SwiftUI


struct ShimmerViewModifier: ViewModifier {
let repeatInterval: Double
@State private var shimmering: Bool = false

Check warning on line 14 in Sources/SpeziViews/ViewModifier/ShimmerModifier.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziViews/ViewModifier/ShimmerModifier.swift#L14

Added line #L14 was not covered by tests


func body(content: Content) -> some View {
content
.opacity(shimmering ? 0.3 : 1)
.animation(.easeInOut(duration: repeatInterval).repeatForever(), value: shimmering)
.onAppear {
shimmering.toggle()
}
}
}

extension View {
/// A view modifier that applies a shimmer animation effect.
///
/// The `ShimmerViewModifier` applies a simple opacity animation that creates a shimmer effect.
/// It can be useful when displaying placeholders for content that is being asynchronously loaded.
/// The `SkeletonLoadingModifier` combines this with a vertical replication of the view to create a skeleton loading effect.
///
/// ### Usage
///
/// ```swift
/// struct ShimmerModifierTestView: View {
/// @State var loading = false
///
/// var body: some View {
/// VStack {
/// ExampleAsyncView(loading: $loading)
/// .processingOverlay(isProcessing: loading) {
/// RoundedRectangle(cornerRadius: 10)
/// .fill(Color(UIColor.systemGray4))
/// .frame(height: 100)
/// .shimmer(repeatInterval: 1.5)
/// }
/// }
/// }
/// }
/// ```
///
NikolaiMadlener marked this conversation as resolved.
Show resolved Hide resolved
/// - Parameters:
/// - repeatInterval: The repeat interval for the shimmer animation.
/// - Returns: The modified view.
public func shimmer(repeatInterval: Double = 1) -> some View {
modifier(ShimmerViewModifier(repeatInterval: max(0, repeatInterval)))
}
}
75 changes: 75 additions & 0 deletions Sources/SpeziViews/ViewModifier/SkeletonLoadingModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SwiftUI


struct SkeletonLoadingViewModifier: ViewModifier {
NikolaiMadlener marked this conversation as resolved.
Show resolved Hide resolved
var replicationCount: Int
var shimmerRepeatInterval: Double
var spacing: CGFloat


func body(content: Content) -> some View {
VStack(spacing: spacing) {
ForEach(0..<replicationCount, id: \.self) { _ in
content
.redacted(reason: .placeholder)
}
}
.shimmer(repeatInterval: shimmerRepeatInterval)
.mask(
LinearGradient(gradient: Gradient(colors: [.black, .clear]), startPoint: .center, endPoint: .bottom)
)
}
}


extension View {
/// A view modifier for adding shimmering placeholder cells, often used as a loading state.
///
/// The `SkeletonLoadingViewModifier` allows you to customize the number of cells,
/// shimmer animation interval, and their appearance by applying it to any SwiftUI view.
/// It can be useful when displaying placeholders for content that is being asynchronously loaded.
///
/// ### Usage
///
/// ```swift
/// struct SkeletonLoadingTestView: View {
/// @State var loading = false
///
/// var body: some View {
/// VStack {
/// ExampleAsyncView(loading: $loading)
/// .processingOverlay(isProcessing: loading) {
/// RoundedRectangle(cornerRadius: 10)
/// .frame(height: 100)
/// .skeletonLoading(replicationCount: 5, repeatInterval: 1.5, spacing: 16)
/// }
/// }
/// }
/// }
/// ```
///
/// @Image(source: "Skeleton-Loading", alt: "The `skeletonLoading` view modifier on a `RoundedRectangle` as placeholder cells.”) {
/// Using the `skeletonLoading` view modifier on a `RoundedRectangle` as placeholder cells.
/// }
///
/// - Parameters:
/// - replicationCount: The number of skeleton cells to display.
/// - repeatInterval: The repeat interval for the shimmer animation.
/// - spacing: The spacing between the skeleton cells.
/// - Returns: A view with the skeleton loading effect applied.
public func skeletonLoading(replicationCount: Int = 1, repeatInterval: Double = 1, spacing: CGFloat = 0) -> some View {
modifier(SkeletonLoadingViewModifier(
replicationCount: max(1, replicationCount),
shimmerRepeatInterval: max(0, repeatInterval),
spacing: max(0, spacing)
))
}
}
2 changes: 1 addition & 1 deletion Sources/SpeziViews/Views/Tiles/TileHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import SwiftUI
/// }
/// }
/// @Column {
/// @Image(source: "Tile-Center", alt: "A `SimpleTile` view with a `TileHeader` view with `center` alignment.") {
/// @Image(source: "Tile-Center", alt: "A `SimpleTile` view with a `TileHeader` view with `center` alignment.") {
/// A `TileHeader` used with the ``SimpleTile`` view and `center` alignment.
/// }
/// }
Expand Down
16 changes: 16 additions & 0 deletions Tests/SpeziViewsTests/SnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,22 @@ final class SnapshotTests: XCTestCase {
#if os(iOS)
assertSnapshot(of: listHeader0, as: .image(layout: .device(config: .iPhone13Pro)), named: "list-header-instructions")
assertSnapshot(of: listHeader1, as: .image(layout: .device(config: .iPhone13Pro)), named: "list-header")
#endif
}

@MainActor
func testSkeletonLoading() {
let view =
VStack {
RoundedRectangle(cornerRadius: 10)
.frame(height: 100)
.skeletonLoading(replicationCount: 5, repeatInterval: 1.5, spacing: 16)
.padding()
Spacer()
}

#if os(iOS)
assertSnapshot(of: view, as: .image(layout: .device(config: .iPhone13Pro)), named: "skeleton-loading")
#endif
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions Tests/UITests/TestApp/Examples/SkeletonLoadingExample.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SpeziViews
import SwiftUI


struct SkeletonLoadingExample: View {
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color(UIColor.systemGray4))
.frame(height: 100)
.skeletonLoading(replicationCount: 5, repeatInterval: 1.5, spacing: 16)
Spacer()
}
.padding()
}
}


#if DEBUG
#Preview {
SkeletonLoadingExample()
}
#endif
3 changes: 3 additions & 0 deletions Tests/UITests/TestApp/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@
},
"Signup" : {

},
"SkeletonLoading" : {

},
"SpeziPersonalInfo" : {

Expand Down
4 changes: 4 additions & 0 deletions Tests/UITests/TestApp/SpeziViewsTargetsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ struct SpeziViewsTargetsTests: View {


var body: some View {
// swiftlint:disable:next closure_body_length
NavigationStack {
List {
Button("SpeziViews") {
Expand Down Expand Up @@ -65,6 +66,9 @@ struct SpeziViewsTargetsTests: View {
NavigationLink("Tiles") {
TileExample()
}
NavigationLink("SkeletonLoading") {
SkeletonLoadingExample()
}
} header: {
Text("Examples")
} footer: {
Expand Down
4 changes: 4 additions & 0 deletions Tests/UITests/UITests.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
2FA9486D29DE91130081C086 /* ViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA9486C29DE91130081C086 /* ViewsTests.swift */; };
2FA9486F29DE91A30081C086 /* SpeziViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2FA9486E29DE91A30081C086 /* SpeziViews */; };
2FB099B82A8AD25300B20952 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */; };
561805A02D4B7F4900141D1B /* SkeletonLoadingExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5618059F2D4B7F4100141D1B /* SkeletonLoadingExample.swift */; };
9731B58F2B167053007676C0 /* ViewStateMapperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9731B58E2B167053007676C0 /* ViewStateMapperView.swift */; };
977CF55C2AD2B92C006D9B54 /* XCTestApp in Frameworks */ = {isa = PBXBuildFile; productRef = 977CF55B2AD2B92C006D9B54 /* XCTestApp */; };
97A0A5102B8D7FD7006102EF /* ConditionalModifierTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97A0A50F2B8D7FD7006102EF /* ConditionalModifierTestView.swift */; };
Expand Down Expand Up @@ -72,6 +73,7 @@
2FA9486C29DE91130081C086 /* ViewsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewsTests.swift; sourceTree = "<group>"; };
2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = "<group>"; };
2FB099B72A8AD25100B20952 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
5618059F2D4B7F4100141D1B /* SkeletonLoadingExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonLoadingExample.swift; sourceTree = "<group>"; };
9731B58E2B167053007676C0 /* ViewStateMapperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewStateMapperView.swift; sourceTree = "<group>"; };
97A0A50F2B8D7FD7006102EF /* ConditionalModifierTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalModifierTestView.swift; sourceTree = "<group>"; };
97EE16AB2B16D5AB004D25A3 /* OperationStateTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationStateTestView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -219,6 +221,7 @@
A9F85B6F2B32A041005F16E6 /* ValidationExample.swift */,
A9F85B712B32A052005F16E6 /* NameFieldsExample.swift */,
A9F85B732B32A05C005F16E6 /* ViewStateExample.swift */,
5618059F2D4B7F4100141D1B /* SkeletonLoadingExample.swift */,
);
path = Examples;
sourceTree = "<group>";
Expand Down Expand Up @@ -368,6 +371,7 @@
A977F6802C93478D0071A1D1 /* ManagedViewStateTests.swift in Sources */,
2FA9486529DE90720081C086 /* ViewStateTestView.swift in Sources */,
A9FBAE952AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift in Sources */,
561805A02D4B7F4900141D1B /* SkeletonLoadingExample.swift in Sources */,
2FA9486929DE90720081C086 /* NameFieldsTestView.swift in Sources */,
A99A65122AF57CA200E63582 /* FocusedValidationTests.swift in Sources */,
A90575B42CD03B2E00B94001 /* CaseIterablePickerTests.swift in Sources */,
Expand Down
Loading