Skip to content

Commit

Permalink
Introduce handling for identifiable onboarding views (#45)
Browse files Browse the repository at this point in the history
# Introduce handling for identifiable onboarding views

## ♻️ Current situation & Problem

Closes #43.
Previously, the `OnboardingStack` identified views by their type name
for ordered navigation operations.
By introducing the protocol `OnboardingIdentifiableView` we require
views to set an `id: String` allowing for multiple views of the same
type to be part of an `OnboardingStack`.

## ⚙️ Release Notes

* `OnboardingIdentifiableView` (new):
* new `protocol` `OnboardingIdentifiableView` conforming to
[`View`](https://developer.apple.com/documentation/swiftui/view) and
[`Identifiable`](https://developer.apple.com/documentation/swift/identifiable)
* add default implementation for `id` that uses the view's type name as
its identifier
* `OnboardingNavigationPath` (changed):
* add new `func append(identifiableView: any
OnboardingIdentifiableView)` that allows navigating to any view that
conforms to the new `OnboardingIdentifiableView` protocol
    
Example Usage:

```swift
struct TestView1: OnboardingIdentifiableView {
    // id = "TestView1" if not explicitly specified

    var body: some View {...}
}

struct TestView2: OnboardingIdentifiableView {
    var id: String = "my-custom-identifier"

    var body: some View {...}
}

struct OnboardingFlow: View {
    var view: some View {
         OnboardingStack {
               TestView2()
               TestView1()
               TestView2(id: "second-test-view-identifier")
         }
    }
}
```

## 📚 Documentation

Documentation added to all new public interface members.

## ✅ Testing

* add new UI Test `testIdentifiableViews` that clicks through a set of
views with both the default view id and custom view identifiers

## 📝 Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md).

---------

Co-authored-by: Paul Schmiedmayer <[email protected]>
  • Loading branch information
felixschlegel and PSchmiedmayer authored Apr 30, 2024
1 parent e699133 commit 8d6dda3
Show file tree
Hide file tree
Showing 11 changed files with 381 additions and 133 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ public class OnboardingNavigationPath {
/// via the ``append(customView:)`` or ``append(customViewInit:)`` instance methods
private var customOnboardingSteps: [OnboardingStepIdentifier: any View] = [:]


/// ``OnboardingStepIdentifier`` of first view in ``OnboardingStack``.
/// `nil` if ``OnboardingStack`` is empty.
private var firstOnboardingStepIdentifier: OnboardingStepIdentifier? {
internal var firstOnboardingStepIdentifier: OnboardingStepIdentifier? {
if onboardingSteps.elements.isEmpty {
return nil
} else {
Expand All @@ -85,10 +85,10 @@ public class OnboardingNavigationPath {
let view = onboardingSteps[firstOnboardingStepIdentifier] else {
return .init(EmptyView())
}

return .init(view)
}

/// Identifier of the current onboarding step that is shown to the user via its associated view.
///
/// Inspects the `OnboardingNavigationPath.path` to determine the current on-top navigation element of the internal SwiftUI `NavigationPath`.
Expand All @@ -100,10 +100,10 @@ public class OnboardingNavigationPath {
guard let lastElement = path.last(where: { !$0.custom }) else {
return firstOnboardingStepIdentifier
}

return lastElement
}

/// An `OnboardingNavigationPath` represents the current navigation path within the ``OnboardingStack``.
/// - Parameters:
/// - views: SwiftUI `View`s that are declared within the ``OnboardingStack``.
Expand All @@ -119,8 +119,8 @@ public class OnboardingNavigationPath {
append(startAtStep)
}
}


/// Moves to the next onboarding step.
///
/// An invocation of this function moves the ``OnboardingNavigationPath`` to the
Expand All @@ -135,12 +135,12 @@ public class OnboardingNavigationPath {
complete?.wrappedValue = true
return
}

appendToInternalNavigationPath(
of: onboardingSteps.elements.keys[currentStepIndex + 1]
)
}

/// Moves the navigation path to the view of the provided type.
///
/// This action integrates seamlessly with the ``nextStep()`` function, meaning one can switch between the ``append(_:)`` and ``nextStep()`` function.
Expand All @@ -150,18 +150,21 @@ public class OnboardingNavigationPath {
/// - Parameters:
/// - onboardingStepType: The type of the onboarding `View` which should be displayed next. Must be declared within the ``OnboardingStack``.
public func append(_ onboardingStepType: any View.Type) {
let onboardingStepIdentifier = OnboardingStepIdentifier(fromType: onboardingStepType)
let onboardingStepIdentifier = OnboardingStepIdentifier(
onboardingStepType: onboardingStepType,
custom: false
)
guard onboardingSteps.keys.contains(onboardingStepIdentifier) else {
print("""
"Warning: Invocation of `OnboardingNavigationPath.append(_:)` with an Onboarding view
that is not delineated in the `OnboardingStack`. Navigation action is void."
""")
return
}

appendToInternalNavigationPath(of: onboardingStepIdentifier)
}

/// Moves the navigation path to the custom view.
///
/// - Note: The custom `View` does not have to be declared within the ``OnboardingStack``.
Expand All @@ -171,19 +174,22 @@ public class OnboardingNavigationPath {
/// - customView: A custom onboarding `View` instance that should be shown next in the onboarding flow.
/// It isn't required to declare this view within the ``OnboardingStack``.
public func append(customView: any View) {
let customOnboardingStepIdentifier = OnboardingStepIdentifier(fromView: customView, custom: true)
let customOnboardingStepIdentifier = OnboardingStepIdentifier(
view: customView,
custom: true
)
customOnboardingSteps[customOnboardingStepIdentifier] = customView

appendToInternalNavigationPath(of: customOnboardingStepIdentifier)
}

/// Removes the last element on top of the navigation path.
///
/// This method allows to manually move backwards within the onboarding navigation flow.
public func removeLast() {
path.removeLast()
}

/// Internal function used to update the onboarding steps within the ``OnboardingNavigationPath`` if the
/// result builder associated with the ``OnboardingStack`` is reevaluated.
///
Expand All @@ -208,24 +214,24 @@ public class OnboardingNavigationPath {
}

for view in views {
let onboardingStepIdentifier = OnboardingStepIdentifier(fromView: view)
let onboardingStepIdentifier = OnboardingStepIdentifier(view: view)
let stepIsAfterCurrentStep = !self.onboardingSteps.keys.contains(onboardingStepIdentifier)
guard stepIsAfterCurrentStep else {
continue
}

guard self.onboardingSteps[onboardingStepIdentifier] == nil else {
preconditionFailure("""
SpeziOnboarding: Duplicate Onboarding step of type `\(onboardingStepIdentifier.onboardingStepType)` identified.
Ensure unique Onboarding view instances within the `OnboardingStack`!
SpeziOnboarding: Duplicate Onboarding step identifier hash `\(onboardingStepIdentifier.identifierHash)` identified.
Ensure unique Onboarding view identifiers within the `OnboardingStack`!
""")
}

self.onboardingSteps[onboardingStepIdentifier] = view
}
onboardingComplete()
}

/// Internal function used to navigate to the respective onboarding `View` via the `NavigationStack.navigationDestination(for:)`,
/// either regularly declared within the ``OnboardingStack`` or custom steps
/// passed via ``append(customView:)`` /``append(customViewInit:)``, identified by the `OnboardingStepIdentifier`.
Expand All @@ -240,17 +246,17 @@ public class OnboardingNavigationPath {
}
return AnyView(view)
}

guard let view = onboardingSteps[onboardingStep] else {
return AnyView(IllegalOnboardingStepView())
}
return AnyView(view)
}

private func appendToInternalNavigationPath(of onboardingStepIdentifier: OnboardingStepIdentifier) {
path.append(onboardingStepIdentifier)
}

private func onboardingComplete() {
if self.onboardingSteps.isEmpty && !(self.complete?.wrappedValue ?? false) {
self.complete?.wrappedValue = true
Expand Down
23 changes: 23 additions & 0 deletions Sources/SpeziOnboarding/OnboardingFlow/OnboardingStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,29 @@ import SwiftUI
/// }
/// }
/// ```
///
/// ### Identifying Onboarding Views
///
/// Apply the ``SwiftUI/View/onboardingIdentifier(_:)`` modifier to clearly identify a view in the `OnboardingStack`.
/// This is particularly useful in scenarios where multiple instances of the same view type might appear in the stack.
///
/// ```swift
/// struct Onboarding: View {
/// @AppStorage(StorageKeys.onboardingFlowComplete) var completedOnboardingFlow = false
///
/// var body: some View {
/// OnboardingStack(onboardingFlowComplete: $completedOnboardingFlow) {
/// MyOwnView()
/// .onboardingIdentifier("my-own-view-1")
/// MyOwnView()
/// .onboardingIdentifier("my-own-view-2")
/// // Other views as needed
/// }
/// }
/// }
/// ```
///
/// - Note: When the ``SwiftUI/View/onboardingIdentifier(_:)`` modifier is applied multiple times to the same view, the outermost identifier takes precedence.
public struct OnboardingStack: View {
@State var onboardingNavigationPath: OnboardingNavigationPath
private let collection: _OnboardingFlowViewCollection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,33 @@ import SwiftUI
///
/// It contains both the identifier for an onboarding step (the view's type) as well as a flag that indicates if it's a custom onboarding step.
struct OnboardingStepIdentifier: Hashable, Codable {
let onboardingStepType: String
let custom: Bool


init(fromType type: any View.Type, custom: Bool = false) {
self.onboardingStepType = String(describing: type)
let identifierHash: Int

/// Initializes an identifier using a view. If the view conforms to `Identifiable`, its `id` is used; otherwise, the view's type is used.
/// - Parameters:
/// - view: The view used to initialize the identifier.
/// - custom: A flag indicating whether the step is custom.
init<V: View>(view: V, custom: Bool = false) {
self.custom = custom
var hasher = Hasher()
if let identifiable = view as? any Identifiable {
let id = identifiable.id
hasher.combine(id)
} else {
hasher.combine(String(describing: type(of: view)))
}
self.identifierHash = hasher.finalize()
}


init(fromView view: any View, custom: Bool = false) {
self.onboardingStepType = String(describing: type(of: view))

/// Initializes an identifier using a view type.
/// - Parameters:
/// - onboardingStepType: The class of the view used to initialize the identifier.
/// - custom: A flag indicating whether the step is custom.
init(onboardingStepType: any View.Type, custom: Bool = false) {
self.custom = custom
var hasher = Hasher()
hasher.combine(String(describing: onboardingStepType))
self.identifierHash = hasher.finalize()
}
}
54 changes: 54 additions & 0 deletions Sources/SpeziOnboarding/OnboardingIdentifiableViewModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// 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


private struct OnboardingIdentifiableViewModifier<ID>: ViewModifier, Identifiable where ID: Hashable {
let id: ID

func body(content: Content) -> some View { content }
}


extension View {
/// Assign a unique identifier to a ``SwiftUI/View`` appearing in an ``OnboardingStack``.
///
/// A `ViewModifier` assigning an identifier to the `View` it is applied to.
/// When applying this modifier repeatedly, the outermost ``SwiftUI/View/onboardingIdentifier(_:)`` counts.
///
/// - Note: This `ViewModifier` should only be used to identify `View`s of the same type within an ``OnboardingStack``.
///
/// - Parameters:
/// - identifier: The `Hashable` identifier given to the view.
///
/// ```swift
/// struct Onboarding: View {
/// @AppStorage(StorageKeys.onboardingFlowComplete) var completedOnboardingFlow = false
///
/// var body: some View {
/// OnboardingStack(onboardingFlowComplete: $completedOnboardingFlow) {
/// MyOwnView()
/// .onboardingIdentifier("my-own-view-1")
/// MyOwnView()
/// .onboardingIdentifier("my-own-view-2")
/// }
/// }
/// }
/// ```
public func onboardingIdentifier<ID>(_ identifier: ID) -> some View where ID: Hashable {
modifier(OnboardingIdentifiableViewModifier(id: identifier))
}
}


extension ModifiedContent: Identifiable where Modifier: Identifiable {
public var id: Modifier.ID {
self.modifier.id
}
}
16 changes: 16 additions & 0 deletions Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,27 @@
//

@testable import SpeziOnboarding
import SwiftUI
import XCTest


final class SpeziOnboardingTests: XCTestCase {
func testSpeziOnboardingTests() throws {
XCTAssert(true)
}

@MainActor
func testOnboardingIdentifierModifier() throws {
let stack = OnboardingStack {
Text("Hello World")
.onboardingIdentifier("Custom Identifier")
}

let identifier = try XCTUnwrap(stack.onboardingNavigationPath.firstOnboardingStepIdentifier)

var hasher = Hasher()
hasher.combine("Custom Identifier")
let final = hasher.finalize()
XCTAssertEqual(identifier.identifierHash, final)
}
}
6 changes: 4 additions & 2 deletions Tests/UITests/TestApp/OnboardingTestsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import SwiftUI
struct OnboardingTestsView: View {
@Binding var onboardingFlowComplete: Bool
@State var showConditionalView = false


var body: some View {
OnboardingStack(onboardingFlowComplete: $onboardingFlowComplete) {
OnboardingStartTestView(
Expand All @@ -25,6 +25,8 @@ struct OnboardingTestsView: View {
OnboardingSequentialTestView()
OnboardingConsentMarkdownTestView()
OnboardingConsentMarkdownRenderingView()
OnboardingTestViewNotIdentifiable(text: "Leland").onboardingIdentifier("a")
OnboardingTestViewNotIdentifiable(text: "Stanford").onboardingIdentifier("b")
OnboardingCustomToggleTestView(showConditionalView: $showConditionalView)

if showConditionalView {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// 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 SpeziOnboarding
import SwiftUI

struct OnboardingIdentifiableTestViewCustom: View, Identifiable {
var id: String

@Environment(OnboardingNavigationPath.self) private var path


var body: some View {
VStack(spacing: 12) {
Text(self.id)

Button {
if self.id == "ID: 1" {
path.append(customView: OnboardingIdentifiableTestViewCustom(id: "ID: 2"))
} else {
path.nextStep()
}
} label: {
Text("Next")
}
}
}
}

#if DEBUG
struct OnboardingIdentifiableTestViewCustomView_Previews: PreviewProvider {
static var previews: some View {
OnboardingStack(startAtStep: OnboardingIdentifiableTestViewCustom.self) {
for onboardingView in OnboardingFlow.previewSimulatorViews {
onboardingView
}
}
}
}
#endif
Loading

0 comments on commit 8d6dda3

Please sign in to comment.