Skip to content

Commit

Permalink
Add InfoButton for list rows (#50)
Browse files Browse the repository at this point in the history
# Add InfoButton for list rows

## ♻️ Current situation & Problem
This PR adds a new icon-only `InfoButton` that is useful in List views
to provide additional context to a row element.


## ⚙️ Release Notes 
* Added new `InfoButton`.


## 📚 Documentation
Added the new button to the documentation catalog.


## ✅ Testing
Tested via UI tests.


## 📝 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).
  • Loading branch information
Supereg authored Dec 18, 2024
1 parent 292a5ac commit 69b0857
Show file tree
Hide file tree
Showing 16 changed files with 267 additions and 31 deletions.
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ let package = Package(
.library(name: "SpeziValidation", targets: ["SpeziValidation"])
],
dependencies: [
.package(url: "https://github.com/StanfordSpezi/Spezi.git", from: "1.3.0"),
.package(url: "https://github.com/StanfordSpezi/Spezi.git", from: "1.8.0"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.17.0")
] + swiftLintPackage(),
Expand Down
10 changes: 3 additions & 7 deletions Sources/SpeziValidation/ValidationEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import SwiftUI
/// processed input and a the respective recovery suggestions for failed ``ValidationRule``s.
/// The state of the `ValidationEngine` is updated on each invocation of ``runValidation(input:)`` or ``submit(input:debounce:)``.
@Observable
public class ValidationEngine: Identifiable {
@MainActor
public final class ValidationEngine: Identifiable {
/// Determines the source of the last validation run.
private enum Source: Equatable {
/// The last validation was run due to change in text field or keyboard submit.
Expand All @@ -31,7 +32,7 @@ public class ValidationEngine: Identifiable {


/// Unique identifier for this validation engine.
public var id: ObjectIdentifier {
public nonisolated var id: ObjectIdentifier {
ObjectIdentifier(self)
}

Expand Down Expand Up @@ -134,7 +135,6 @@ public class ValidationEngine: Identifiable {
self.init(rules: validationRules, debounceFor: debounceDuration, configuration: configuration)
}

@MainActor
private func computeFailedValidations(input: String) -> [FailedValidationResult] {
var results: [FailedValidationResult] = []

Expand All @@ -153,7 +153,6 @@ public class ValidationEngine: Identifiable {
}


@MainActor
private func computeValidation(input: String, source: Source) {
self.source = source
self.inputWasEmpty = input.isEmpty
Expand All @@ -174,7 +173,6 @@ public class ValidationEngine: Identifiable {
/// there not further calls to this method for the configured `debounceDuration`. If set to `false` the method
/// will run immediately. Note that the validation will still run instantly, if we are currently in an invalid state
/// to ensure input validity is reported immediately.
@MainActor
public func submit(input: String, debounce: Bool = false) {
if !debounce || computedInputValid == false {
// we compute instantly, if debounce is false or if we are in a invalid state
Expand All @@ -191,12 +189,10 @@ public class ValidationEngine: Identifiable {
///
/// The input is considered valid if all ``ValidationRule``s succeed.
/// - Parameter input: The input to validate.
@MainActor
public func runValidation(input: String) {
computeValidation(input: input, source: .manual)
}

@MainActor
private func debounce(_ task: @escaping () -> Void) {
debounceTask = Task {
try? await Task.sleep(for: debounceDuration)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import SwiftUI
///
/// This particularly allows to run a validation from the outside of a view.
@dynamicMemberLookup
@MainActor
public struct CapturedValidationState {
private let engine: ValidationEngine
private let input: String
private nonisolated let engine: ValidationEngine
private nonisolated let input: String
private let focusState: FocusState<Bool>.Binding

init(engine: ValidationEngine, input: String, focus focusState: FocusState<Bool>.Binding) {
Expand Down Expand Up @@ -46,8 +47,8 @@ public struct CapturedValidationState {
}


extension CapturedValidationState: Equatable {
public static func == (lhs: CapturedValidationState, rhs: CapturedValidationState) -> Bool {
extension CapturedValidationState: Equatable, Sendable {
public static nonisolated func == (lhs: CapturedValidationState, rhs: CapturedValidationState) -> Bool {
lhs.engine === rhs.engine && lhs.input == rhs.input
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
import SwiftUI


@MainActor
private struct IsolatedValidationBinding: Sendable {
let state: ValidationState.Binding

init(_ state: ValidationState.Binding) {
self.state = state
}
}


/// Provide access to validation state to the parent view.
///
/// The internal preference key to provide parent views access to all configured ``ValidationEngine`` and input
Expand Down Expand Up @@ -36,8 +46,12 @@ extension View {
/// - Parameter state: The binding to the ``ValidationState``.
/// - Returns: The modified view.
public func receiveValidation(in state: ValidationState.Binding) -> some View {
onPreferenceChange(CapturedValidationStateKey.self) { entries in
state.wrappedValue = ValidationContext(entries: entries)
let binding = IsolatedValidationBinding(state)

return onPreferenceChange(CapturedValidationStateKey.self) { entries in
Task { @Sendable @MainActor in
binding.state.wrappedValue = ValidationContext(entries: entries)
}
}
}
}
2 changes: 2 additions & 0 deletions Sources/SpeziViews/SpeziViews.docc/SpeziViews.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,14 @@ Default layouts and utilities to automatically adapt your view layouts to dynami
- ``DynamicHStack``
- ``ListRow``
- ``DescriptionGridRow``
- ``ListHeader``

### Controls

- ``AsyncButton``
- ``SwiftUICore/EnvironmentValues/processingDebounceDuration``
- ``CanvasView``
- ``InfoButton``
- ``DismissButton``
- ``CaseIterablePicker``

Expand Down
69 changes: 69 additions & 0 deletions Sources/SpeziViews/Views/Button/InfoButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// 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


/// Icon-only info button.
///
/// You can use this button, e.g., on the trailing side of a list row to provide additional information about an entity.
public struct InfoButton: View {
private let label: Text
private let action: () -> Void

public var body: some View {
Button(action: action) {
SwiftUI.Label {
label
} icon: {
Image(systemName: "info.circle") // swiftlint:disable:this accessibility_label_for_image
}
}
.labelStyle(.iconOnly)
.font(.title3)
.foregroundColor(.accentColor)
.buttonStyle(.borderless) // ensure button is clickable next to the other button
.accessibilityIdentifier("info-button")
.accessibilityAction(named: label, action)
}

/// Create a new info button.
/// - Parameters:
/// - label: The text label. This is not shown but useful for accessibility.
/// - action: The button action.
public init(_ label: Text, action: @escaping () -> Void) {
self.label = label
self.action = action
}

/// Create a new info button.
/// - Parameters:
/// - resource: The localized button label. This is not shown but useful for accessibility.
/// - action: The button action.
public init(_ resource: LocalizedStringResource, action: @escaping () -> Void) {
self.label = Text(resource)
self.action = action
}
}


#if DEBUG
#Preview {
List {
Button {
print("Primary")
} label: {
ListRow("Entry") {
InfoButton("Entry Info") {
print("Info")
}
}
}
}
}
#endif
2 changes: 1 addition & 1 deletion Sources/SpeziViews/Views/Controls/CaseIterablePicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ extension CaseIterablePicker where Value: AnyOptional { // swiftlint:disable:thi
/// Create a new case-iterable picker.
/// - Parameters:
/// - titleKey: The picker label.
/// - value: The value binding.
/// - selection: The value binding.
public init(_ titleKey: LocalizedStringResource, selection: Binding<Value>) where Label == Text {
self.init(selection: selection) {
Text(titleKey)
Expand Down
105 changes: 105 additions & 0 deletions Sources/SpeziViews/Views/List/ListHeader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//
// This source file is part of the Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import SwiftUI


/// Header view for Lists or Forms.
///
/// A header view that can be used in List or Form views.
public struct ListHeader<Image: View, Title: View, Instructions: View>: View {
private let image: Image
private let title: Title
private let instructions: Instructions


public var body: some View {
VStack {
VStack {
image
.foregroundColor(.accentColor)
.symbolRenderingMode(.multicolor)
.font(.custom("XXL", size: 50, relativeTo: .title))
.accessibilityHidden(true)
title
.accessibilityAddTraits(.isHeader)
.font(.title)
.bold()
.padding(.bottom, 4)
}
.accessibilityElement(children: .combine)
instructions
.padding([.leading, .trailing], 25)
}
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}

/// Create a new list header.
/// - Parameters:
/// - image: The image view.
/// - title: The title view.
public init(@ViewBuilder image: () -> Image, @ViewBuilder title: () -> Title) where Instructions == EmptyView {
self.init(image: image, title: title) {
EmptyView()
}
}

/// Create a new list header.
/// - Parameters:
/// - image: The image view.
/// - title: The title view.
/// - instructions: The instructions subheadline.
public init(@ViewBuilder image: () -> Image, @ViewBuilder title: () -> Title, @ViewBuilder instructions: () -> Instructions) {
self.image = image()
self.title = title()
self.instructions = instructions()
}

/// Create a new list header.
/// - Parameters:
/// - systemImage: The name of the system symbol image.
/// - title: The title view.
public init(systemImage: String, @ViewBuilder title: () -> Title) where Image == SwiftUI.Image, Instructions == EmptyView {
// swiftlint:disable:next accessibility_label_for_image
self.init(image: { SwiftUI.Image(systemName: systemImage) }, title: title) {
EmptyView()
}
}

/// Create a new list header.
/// - Parameters:
/// - systemImage: The name of the system symbol image.
/// - title: The title view.
/// - instructions: The instructions subheadline.
public init(systemImage: String, @ViewBuilder title: () -> Title, @ViewBuilder instructions: () -> Instructions) where Image == SwiftUI.Image {
// swiftlint:disable:next accessibility_label_for_image
self.init(image: { SwiftUI.Image(systemName: systemImage) }, title: title, instructions: instructions)
}
}


#if DEBUG
#Preview {
List {
ListHeader(systemImage: "person.fill.badge.plus") {
Text("Create a new Account", bundle: .module)
} instructions: {
Text("Please fill out the details below to create your new account.", bundle: .module)
}
}
}

#Preview {
List {
ListHeader(systemImage: "person.fill.badge.plus") {
Text("Create a new Account", bundle: .module)
}
}
}
#endif
18 changes: 18 additions & 0 deletions Tests/SpeziViewsTests/SnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,22 @@ final class SnapshotTests: XCTestCase {
Text("World")
}
}

@MainActor
func testListHeader() {
let listHeader0 = ListHeader(systemImage: "person.fill.badge.plus") {
Text("Create a new Account", bundle: .module)
} instructions: {
Text("Please fill out the details below to create your new account.", bundle: .module)
}

let listHeader1 = ListHeader(systemImage: "person.fill.badge.plus") {
Text("Create a new Account", bundle: .module)
}

#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
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions Tests/UITests/TestApp/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@
},
"Enter your details" : {

},
"Entity" : {

},
"Entity Info" : {

},
"Error Description" : {

Expand Down
Loading

0 comments on commit 69b0857

Please sign in to comment.