Add ProgressView (#425)
This adds `ProgressView` using the `<progress>` tag on the web. * Add ProgressView implementation * Fix native demo * Enable Foundation.Progress in non-WASI environments * Fix wasm build * Update progress coc * Improve snapshot copy error handling * Use RenderingTests as directory name * Fix snapshots CI script * Make test failures fail the CI job * Snapshot script debugging * Copy failed snapshots in a different way * Call `exit 1` when tests fail * Use correct directory in the upload step * Update test image * Update .github/workflows/ci.yml Co-authored-by: ezraberch <49635435+ezraberch@users.noreply.github.com> Co-authored-by: Max Desiatov <max@desiatov.com>
This commit is contained in:
parent
ab5e564ada
commit
12a6256ec0
|
@ -37,7 +37,7 @@ jobs:
|
|||
# avoid building unrelated products for testing by specifying the test product explicitly
|
||||
swift build --product TokamakPackageTests
|
||||
`xcrun --find xctest` .build/debug/TokamakPackageTests.xctest ||
|
||||
find /var/folders -iname SnapshotTests -exec cp -r {} . \;
|
||||
(cp -r `find /var/folders -iname RenderingTests` . ; exit 1)
|
||||
|
||||
rm -rf Sources/TokamakGTKCHelpers/*.c
|
||||
|
||||
|
@ -61,7 +61,7 @@ jobs:
|
|||
if: ${{ failure() }}
|
||||
with:
|
||||
name: Failed snapshots
|
||||
path: SnapshotTests
|
||||
path: RenderingTests
|
||||
|
||||
gtk_macos_build:
|
||||
runs-on: macos-latest
|
||||
|
|
|
@ -11,6 +11,8 @@
|
|||
207C05712610E16E00BBBE54 /* DatePickerDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */; };
|
||||
262DA7B32695D99500CABEAE /* ShapeStyleDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262DA7B22695D99500CABEAE /* ShapeStyleDemo.swift */; };
|
||||
262DA7B42695D99500CABEAE /* ShapeStyleDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262DA7B22695D99500CABEAE /* ShapeStyleDemo.swift */; };
|
||||
26AC04B62698D33A0057784E /* ProgressViewDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26AC04B52698D33A0057784E /* ProgressViewDemo.swift */; };
|
||||
26AC04B72698D33A0057784E /* ProgressViewDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26AC04B52698D33A0057784E /* ProgressViewDemo.swift */; };
|
||||
26A3BFB0269BD18A0004DA16 /* AnimationDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A3BFAF269BD18A0004DA16 /* AnimationDemo.swift */; };
|
||||
26A3BFB1269BD18A0004DA16 /* AnimationDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A3BFAF269BD18A0004DA16 /* AnimationDemo.swift */; };
|
||||
3DCDE44424CA6AD400910F17 /* SidebarDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DCDE44324CA6AD400910F17 /* SidebarDemo.swift */; };
|
||||
|
@ -102,6 +104,7 @@
|
|||
/* Begin PBXFileReference section */
|
||||
207C056F2610E16E00BBBE54 /* DatePickerDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatePickerDemo.swift; sourceTree = "<group>"; };
|
||||
262DA7B22695D99500CABEAE /* ShapeStyleDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShapeStyleDemo.swift; sourceTree = "<group>"; };
|
||||
26AC04B52698D33A0057784E /* ProgressViewDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgressViewDemo.swift; sourceTree = "<group>"; };
|
||||
26A3BFAF269BD18A0004DA16 /* AnimationDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimationDemo.swift; sourceTree = "<group>"; };
|
||||
3DCDE44324CA6AD400910F17 /* SidebarDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SidebarDemo.swift; sourceTree = "<group>"; };
|
||||
4550BD5125B642B80088F4EA /* ShadowDemo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShadowDemo.swift; sourceTree = "<group>"; };
|
||||
|
@ -214,6 +217,7 @@
|
|||
B51F214F24B920B400CF2583 /* PathDemo.swift */,
|
||||
D1EE7EA624C0DD2100C0D127 /* PickerDemo.swift */,
|
||||
B5F2BE022571443D00FB3653 /* PreferenceKeyDemo.swift */,
|
||||
26AC04B52698D33A0057784E /* ProgressViewDemo.swift */,
|
||||
B5DBA22A24D509B4003D3347 /* RedactDemo.swift */,
|
||||
3DCDE44324CA6AD400910F17 /* SidebarDemo.swift */,
|
||||
8500293E24D2FF3E001A2E84 /* SliderDemo.swift */,
|
||||
|
@ -394,6 +398,7 @@
|
|||
D1C726F324CB63C6003B576D /* ButtonStyleDemo.swift in Sources */,
|
||||
854A1A9124B3E3630027BC32 /* ToggleDemo.swift in Sources */,
|
||||
85ED18A524AD425E0085DFA0 /* TextDemo.swift in Sources */,
|
||||
26AC04B62698D33A0057784E /* ProgressViewDemo.swift in Sources */,
|
||||
85ED18AB24AD425E0085DFA0 /* Counter.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -429,6 +434,7 @@
|
|||
D1C726F424CB63C6003B576D /* ButtonStyleDemo.swift in Sources */,
|
||||
854A1A9324B3F28F0027BC32 /* ToggleDemo.swift in Sources */,
|
||||
85ED18AE24AD425E0085DFA0 /* TextFieldDemo.swift in Sources */,
|
||||
26AC04B72698D33A0057784E /* ProgressViewDemo.swift in Sources */,
|
||||
85ED18A624AD425E0085DFA0 /* TextDemo.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
|
|
@ -0,0 +1,212 @@
|
|||
// Copyright 2020 Tokamak contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Created by Carson Katri on 7/9/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct ProgressView<Label, CurrentValueLabel>: View
|
||||
where Label: View, CurrentValueLabel: View
|
||||
{
|
||||
let storage: Storage
|
||||
|
||||
enum Storage {
|
||||
case custom(_CustomProgressView<Label, CurrentValueLabel>)
|
||||
case foundation(_FoundationProgressView)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
switch storage {
|
||||
case let .custom(custom): custom
|
||||
case let .foundation(foundation): foundation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct _CustomProgressView<Label, CurrentValueLabel>: View
|
||||
where Label: View, CurrentValueLabel: View
|
||||
{
|
||||
var fractionCompleted: Double?
|
||||
var label: Label?
|
||||
var currentValueLabel: CurrentValueLabel?
|
||||
|
||||
@Environment(\.progressViewStyle) var style
|
||||
|
||||
init(
|
||||
fractionCompleted: Double?,
|
||||
label: Label?,
|
||||
currentValueLabel: CurrentValueLabel?
|
||||
) {
|
||||
self.fractionCompleted = fractionCompleted
|
||||
self.label = label
|
||||
self.currentValueLabel = currentValueLabel
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
style.makeBody(
|
||||
configuration: .init(
|
||||
fractionCompleted: fractionCompleted,
|
||||
label: label.map { .init(body: AnyView($0)) },
|
||||
currentValueLabel: currentValueLabel.map { .init(body: AnyView($0)) }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(WASI)
|
||||
public struct _FoundationProgressView: View {
|
||||
public var body: Never {
|
||||
fatalError("`Foundation.Progress` is not available.")
|
||||
}
|
||||
}
|
||||
#else
|
||||
public struct _FoundationProgressView: View {
|
||||
let progress: Progress
|
||||
@State private var state: ProgressState?
|
||||
|
||||
struct ProgressState {
|
||||
var progress: Double
|
||||
var isIndeterminate: Bool
|
||||
var description: String
|
||||
}
|
||||
|
||||
init(_ progress: Progress) {
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ProgressView(
|
||||
value: progress.isIndeterminate ? nil : progress.fractionCompleted
|
||||
) {
|
||||
Text("\(Int(progress.fractionCompleted * 100))% completed")
|
||||
} currentValueLabel: {
|
||||
Text("\(progress.completedUnitCount)/\(progress.totalUnitCount)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Override in renderers to provide a default body for determinate progress views.
|
||||
public struct _FractionalProgressView: _PrimitiveView {
|
||||
public let fractionCompleted: Double
|
||||
init(_ fractionCompleted: Double) {
|
||||
self.fractionCompleted = fractionCompleted
|
||||
}
|
||||
}
|
||||
|
||||
/// Override in renderers to provide a default body for indeterminate progress views.
|
||||
public struct _IndeterminateProgressView: _PrimitiveView {}
|
||||
|
||||
public extension ProgressView where CurrentValueLabel == EmptyView {
|
||||
init() where Label == EmptyView {
|
||||
self.init(storage: .custom(
|
||||
.init(fractionCompleted: nil, label: nil, currentValueLabel: nil)
|
||||
))
|
||||
}
|
||||
|
||||
init(@ViewBuilder label: () -> Label) {
|
||||
self.init(storage: .custom(
|
||||
.init(fractionCompleted: nil, label: label(), currentValueLabel: nil)
|
||||
))
|
||||
}
|
||||
|
||||
init<S>(_ title: S) where Label == Text, S: StringProtocol {
|
||||
self.init {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension ProgressView {
|
||||
init<V>(
|
||||
value: V?,
|
||||
total: V = 1.0
|
||||
) where Label == EmptyView, CurrentValueLabel == EmptyView, V: BinaryFloatingPoint {
|
||||
self.init(storage: .custom(
|
||||
.init(
|
||||
fractionCompleted: value.map { Double($0 / total) },
|
||||
label: nil,
|
||||
currentValueLabel: nil
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
init<V>(
|
||||
value: V?,
|
||||
total: V = 1.0,
|
||||
@ViewBuilder label: () -> Label
|
||||
) where CurrentValueLabel == EmptyView, V: BinaryFloatingPoint {
|
||||
self.init(storage: .custom(
|
||||
.init(
|
||||
fractionCompleted: value.map { Double($0 / total) },
|
||||
label: label(),
|
||||
currentValueLabel: nil
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
init<V>(
|
||||
value: V?,
|
||||
total: V = 1.0,
|
||||
@ViewBuilder label: () -> Label,
|
||||
@ViewBuilder currentValueLabel: () -> CurrentValueLabel
|
||||
) where V: BinaryFloatingPoint {
|
||||
self.init(storage: .custom(
|
||||
.init(
|
||||
fractionCompleted: value.map { Double($0 / total) },
|
||||
label: label(),
|
||||
currentValueLabel: currentValueLabel()
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
init<S, V>(
|
||||
_ title: S,
|
||||
value: V?,
|
||||
total: V = 1.0
|
||||
) where Label == Text, CurrentValueLabel == EmptyView, S: StringProtocol, V: BinaryFloatingPoint {
|
||||
self.init(
|
||||
value: value,
|
||||
total: total
|
||||
) {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(WASI)
|
||||
public extension ProgressView {
|
||||
init(_ progress: Progress) where Label == EmptyView, CurrentValueLabel == EmptyView {
|
||||
self.init(storage: .foundation(.init(progress)))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
public extension ProgressView {
|
||||
init(_ configuration: ProgressViewStyleConfiguration)
|
||||
where Label == ProgressViewStyleConfiguration.Label,
|
||||
CurrentValueLabel == ProgressViewStyleConfiguration.CurrentValueLabel
|
||||
{
|
||||
self.init(value: configuration.fractionCompleted) {
|
||||
ProgressViewStyleConfiguration.Label(
|
||||
body: AnyView(configuration.label)
|
||||
)
|
||||
} currentValueLabel: {
|
||||
ProgressViewStyleConfiguration.CurrentValueLabel(
|
||||
body: AnyView(configuration.currentValueLabel)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
// Copyright 2020 Tokamak contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Created by Carson Katri on 7/9/21.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol ProgressViewStyle {
|
||||
associatedtype Body: View
|
||||
typealias Configuration = ProgressViewStyleConfiguration
|
||||
|
||||
@ViewBuilder
|
||||
func makeBody(configuration: Self.Configuration) -> Self.Body
|
||||
}
|
||||
|
||||
public struct ProgressViewStyleConfiguration {
|
||||
public struct Label: View {
|
||||
public let body: AnyView
|
||||
}
|
||||
|
||||
public struct CurrentValueLabel: View {
|
||||
public let body: AnyView
|
||||
}
|
||||
|
||||
public let fractionCompleted: Double?
|
||||
public var label: ProgressViewStyleConfiguration.Label?
|
||||
public var currentValueLabel: ProgressViewStyleConfiguration.CurrentValueLabel?
|
||||
}
|
||||
|
||||
public struct DefaultProgressViewStyle: ProgressViewStyle {
|
||||
public init() {}
|
||||
public func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack { Spacer() }
|
||||
configuration.label
|
||||
.foregroundStyle(PrimaryContentStyle())
|
||||
if let fractionCompleted = configuration.fractionCompleted {
|
||||
_FractionalProgressView(fractionCompleted)
|
||||
} else {
|
||||
_IndeterminateProgressView()
|
||||
}
|
||||
configuration.currentValueLabel
|
||||
.font(.caption)
|
||||
.foregroundStyle(PrimaryContentStyle())
|
||||
.opacity(0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct _AnyProgressViewStyle: ProgressViewStyle {
|
||||
public typealias Body = AnyView
|
||||
|
||||
private let bodyClosure: (ProgressViewStyleConfiguration) -> AnyView
|
||||
public let type: Any.Type
|
||||
|
||||
public init<S: ProgressViewStyle>(_ style: S) {
|
||||
type = S.self
|
||||
bodyClosure = { configuration in
|
||||
AnyView(style.makeBody(configuration: configuration))
|
||||
}
|
||||
}
|
||||
|
||||
public func makeBody(configuration: ProgressViewStyleConfiguration) -> AnyView {
|
||||
bodyClosure(configuration)
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
private enum ProgressViewStyleKey: EnvironmentKey {
|
||||
static let defaultValue = _AnyProgressViewStyle(DefaultProgressViewStyle())
|
||||
}
|
||||
|
||||
var progressViewStyle: _AnyProgressViewStyle {
|
||||
get {
|
||||
self[ProgressViewStyleKey.self]
|
||||
}
|
||||
set {
|
||||
self[ProgressViewStyleKey.self] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func progressViewStyle<S>(_ style: S) -> some View where S: ProgressViewStyle {
|
||||
environment(\.progressViewStyle, .init(style))
|
||||
}
|
||||
}
|
|
@ -130,6 +130,7 @@ public typealias NavigationLink = TokamakCore.NavigationLink
|
|||
public typealias NavigationView = TokamakCore.NavigationView
|
||||
public typealias OutlineGroup = TokamakCore.OutlineGroup
|
||||
public typealias Picker = TokamakCore.Picker
|
||||
public typealias ProgressView = TokamakCore.ProgressView
|
||||
public typealias ScrollView = TokamakCore.ScrollView
|
||||
public typealias Section = TokamakCore.Section
|
||||
public typealias SecureField = TokamakCore.SecureField
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
// Copyright 2020 Tokamak contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Created by Carson Katri on 7/9/21.
|
||||
//
|
||||
|
||||
import TokamakShim
|
||||
|
||||
struct ProgressViewDemo: View {
|
||||
@State private var progress = 0.5
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack { Spacer() }
|
||||
|
||||
ProgressView("Indeterminate")
|
||||
ProgressView(value: progress) {
|
||||
Text("Determinate")
|
||||
} currentValueLabel: {
|
||||
Text("\(progress)")
|
||||
}
|
||||
ProgressView("Increased Total", value: progress, total: 2)
|
||||
Button("Make Progress") { progress += 0.1 }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -138,6 +138,7 @@ struct TokamakDemoView: View {
|
|||
Section(header: Text("Misc")) {
|
||||
NavItem("Animation", destination: AnimationDemo())
|
||||
NavItem("Path", destination: PathDemo())
|
||||
NavItem("ProgressView", destination: ProgressViewDemo())
|
||||
NavItem("Environment", destination: EnvironmentDemo().font(.system(size: 8)))
|
||||
if #available(macOS 11.0, iOS 14.0, *) {
|
||||
NavItem("Preferences", destination: PreferenceKeyDemo())
|
||||
|
|
|
@ -75,6 +75,7 @@ public typealias HStack = TokamakCore.HStack
|
|||
public typealias LazyHGrid = TokamakCore.LazyHGrid
|
||||
public typealias LazyVGrid = TokamakCore.LazyVGrid
|
||||
public typealias List = TokamakCore.List
|
||||
public typealias ProgressView = TokamakCore.ProgressView
|
||||
public typealias ScrollView = TokamakCore.ScrollView
|
||||
public typealias Section = TokamakCore.Section
|
||||
public typealias Spacer = TokamakCore.Spacer
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
// Copyright 2020 Tokamak contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Created by Carson Katri on 7/9/21.
|
||||
//
|
||||
|
||||
import TokamakCore
|
||||
|
||||
extension _FractionalProgressView: _HTMLPrimitive {
|
||||
public var renderedBody: AnyView {
|
||||
AnyView(
|
||||
HTML("progress", [
|
||||
"value": "\(fractionCompleted)",
|
||||
"style": "width: 100%;",
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension _IndeterminateProgressView: _HTMLPrimitive {
|
||||
public var renderedBody: AnyView {
|
||||
AnyView(
|
||||
HTML("progress", ["style": "width: 100%;"])
|
||||
)
|
||||
}
|
||||
}
|
|
@ -255,6 +255,21 @@ final class RenderingTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testProgressView() {
|
||||
assertSnapshot(
|
||||
matching: VStack(spacing: 0) {
|
||||
ProgressView(value: 0.5) {
|
||||
Text("Loading")
|
||||
} currentValueLabel: {
|
||||
Text("0.5")
|
||||
}
|
||||
ProgressView(Progress(totalUnitCount: 3))
|
||||
},
|
||||
as: .image(size: .init(width: 200, height: 200)),
|
||||
timeout: defaultSnapshotTimeout
|
||||
)
|
||||
}
|
||||
|
||||
func testAspectRatio() {
|
||||
assertSnapshot(
|
||||
matching: Ellipse()
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 6.2 KiB |
|
@ -53,7 +53,7 @@ Table columns:
|
|||
|
||||
| | | |
|
||||
| --- | ------------------------------------------------------------------------------ | :-: |
|
||||
| | [ProgressView](https://developer.apple.com/documentation/swiftui/progressview) | |
|
||||
| 🚧 | [ProgressView](https://developer.apple.com/documentation/swiftui/progressview) | |
|
||||
| | [Gauge](https://developer.apple.com/documentation/swiftui/gauge) | |
|
||||
| | [Label](https://developer.apple.com/documentation/swiftui/label) | |
|
||||
| ✅ | [Link](https://developer.apple.com/documentation/swiftui/link) | |
|
||||
|
|
Loading…
Reference in New Issue