Skip to content

Commit a2a18c2

Browse files
authored
Merge pull request #141 from liveviewnative/progress-view
Add `ProgressView`
2 parents 249b479 + 82f8c95 commit a2a18c2

File tree

8 files changed

+184
-29
lines changed

8 files changed

+184
-29
lines changed

Sources/LiveViewNative/BuiltinRegistry.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ struct BuiltinRegistry {
5454
Shape(element: element, context: context, shape: ContainerRelativeShape())
5555
case "lvn-link":
5656
Link(element: element, context: context)
57+
case "progress-view":
58+
ProgressView(element: element, context: context)
5759
case "divider":
5860
Divider()
5961
case "edit-button":

Sources/LiveViewNative/LiveContext.swift

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,11 @@ public struct LiveContext<R: CustomRegistry> {
4747
return coordinator.builder.fromNodes(element.children(), context: self)
4848
}
4949

50-
public func buildChildren(
51-
of element: ElementNode,
52-
withTagName tagName: String,
53-
namespace: String? = nil,
54-
includeDefaultSlot: Bool = false
55-
) -> some View {
56-
let children = element.children()
57-
let namedSlotChildren = children.filter({ child in
50+
private static func elementWithName(
51+
_ tagName: String,
52+
namespace: String?
53+
) -> (NodeChildrenSequence.Element) -> Bool {
54+
{ child in
5855
if case let .element(element) = child.data,
5956
element.namespace == namespace,
6057
element.tag == tagName
@@ -63,7 +60,25 @@ public struct LiveContext<R: CustomRegistry> {
6360
} else {
6461
return false
6562
}
66-
})
63+
}
64+
}
65+
66+
public func hasChild(
67+
of element: ElementNode,
68+
withTagName tagName: String,
69+
namespace: String? = nil
70+
) -> Bool {
71+
element.children().contains(where: Self.elementWithName(tagName, namespace: namespace))
72+
}
73+
74+
public func buildChildren(
75+
of element: ElementNode,
76+
withTagName tagName: String,
77+
namespace: String? = nil,
78+
includeDefaultSlot: Bool = false
79+
) -> some View {
80+
let children = element.children()
81+
let namedSlotChildren = children.filter(Self.elementWithName(tagName, namespace: namespace))
6782
if namedSlotChildren.isEmpty && includeDefaultSlot {
6883
let defaultSlotChildren = children.filter({
6984
if case let .element(element) = $0.data {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//
2+
// DateParsing.swift
3+
//
4+
//
5+
// Created by Carson Katri on 1/17/23.
6+
//
7+
8+
import Foundation
9+
10+
/// A formatter that parses ISO8601 dates as produced by Elixir's `DateTime`.
11+
fileprivate let dateTimeFormatter: ISO8601DateFormatter = {
12+
let formatter = ISO8601DateFormatter()
13+
formatter.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds]
14+
return formatter
15+
}()
16+
17+
/// A formatter that parses ISO8601 dates as produced by Elixir's `Date`.
18+
fileprivate let dateFormatter: DateFormatter = {
19+
let formatter = DateFormatter()
20+
formatter.dateFormat = "yyyy-MM-dd"
21+
return formatter
22+
}()
23+
24+
struct ElixirDateFormat: ParseableFormatStyle {
25+
typealias FormatInput = Date
26+
27+
typealias FormatOutput = String
28+
29+
func format(_ value: Date) -> String {
30+
dateTimeFormatter.string(from: value)
31+
}
32+
33+
var parseStrategy = ElixirDateParseStrategy()
34+
}
35+
36+
struct ElixirDateParseStrategy: ParseStrategy {
37+
func parse(_ value: String) throws -> Date {
38+
guard let value = dateTimeFormatter.date(from: value) ?? dateFormatter.date(from: value)
39+
else { throw DateParseError.invalidDate }
40+
return value
41+
}
42+
43+
enum DateParseError: Error {
44+
case invalidDate
45+
}
46+
}
47+
48+
extension FormatStyle where Self == ElixirDateFormat {
49+
static var elixirDate: ElixirDateFormat { .init() }
50+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
//
2+
// Link.swift
3+
//
4+
//
5+
// Created by Carson Katri on 1/17/23.
6+
//
7+
8+
import SwiftUI
9+
10+
struct ProgressView<R: CustomRegistry>: View {
11+
@ObservedElement private var element: ElementNode
12+
let context: LiveContext<R>
13+
14+
init(element: ElementNode, context: LiveContext<R>) {
15+
self.context = context
16+
}
17+
18+
public var body: some View {
19+
Group {
20+
if let timerIntervalStart = element.attributeValue(for: "timer-interval-start").flatMap({ try? ElixirDateParseStrategy().parse($0) }),
21+
let timerIntervalEnd = element.attributeValue(for: "timer-interval-end").flatMap({ try? ElixirDateParseStrategy().parse($0) })
22+
{
23+
// SwiftUI's default `currentValueLabel` is not present unless the argument is not included in the initializer.
24+
// Check if we have it first otherwise use the default.
25+
if context.hasChild(of: element, withTagName: "current-value-label", namespace: "progress-view") {
26+
SwiftUI.ProgressView(
27+
timerInterval: timerIntervalStart...timerIntervalEnd,
28+
countsDown: element.attributeValue(for: "counts-down") != "false"
29+
) {
30+
context.buildChildren(of: element, withTagName: "label", namespace: "progress-view", includeDefaultSlot: true)
31+
} currentValueLabel: {
32+
context.buildChildren(of: element, withTagName: "current-value-label", namespace: "progress-view")
33+
}
34+
} else {
35+
SwiftUI.ProgressView(
36+
timerInterval: timerIntervalStart...timerIntervalEnd,
37+
countsDown: element.attributeValue(for: "counts-down") != "false"
38+
) {
39+
context.buildChildren(of: element, withTagName: "label", namespace: "progress-view", includeDefaultSlot: true)
40+
}
41+
}
42+
} else if let value = element.attributeValue(for: "value").flatMap(Double.init) {
43+
SwiftUI.ProgressView(
44+
value: value,
45+
total: element.attributeValue(for: "total").flatMap(Double.init) ?? 1
46+
) {
47+
context.buildChildren(of: element, withTagName: "label", namespace: "progress-view", includeDefaultSlot: true)
48+
} currentValueLabel: {
49+
context.buildChildren(of: element, withTagName: "current-value-label", namespace: "progress-view")
50+
}
51+
} else {
52+
SwiftUI.ProgressView {
53+
context.buildChildren(of: element, withTagName: "label", namespace: "progress-view", includeDefaultSlot: true)
54+
}
55+
}
56+
}
57+
.applyProgressViewStyle(element.attributeValue(for: "progress-view-style").flatMap(ProgressViewStyle.init) ?? .automatic)
58+
}
59+
}
60+
61+
fileprivate enum ProgressViewStyle: String {
62+
case automatic
63+
case linear
64+
case circular
65+
}
66+
67+
fileprivate extension View {
68+
@ViewBuilder
69+
func applyProgressViewStyle(_ style: ProgressViewStyle) -> some View {
70+
switch style {
71+
case .automatic:
72+
self.progressViewStyle(.automatic)
73+
case .linear:
74+
self.progressViewStyle(.linear)
75+
case .circular:
76+
self.progressViewStyle(.circular)
77+
}
78+
}
79+
}

Sources/LiveViewNative/Views/Controls and Indicators/Value Inputs/Slider.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ struct Slider<R: CustomRegistry>: View {
2626
in: lowerBound...upperBound,
2727
step: step
2828
) {
29-
context.buildChildren(of: element, defaultSlotFor: "slider")
30-
context.buildChildren(of: element, withTagName: "label", namespace: "slider")
29+
context.buildChildren(of: element, withTagName: "label", namespace: "slider", includeDefaultSlot: true)
3130
} minimumValueLabel: {
3231
context.buildChildren(of: element, withTagName: "minimum-value-label", namespace: "slider")
3332
} maximumValueLabel: {
@@ -38,8 +37,7 @@ struct Slider<R: CustomRegistry>: View {
3837
value: $value,
3938
in: lowerBound...upperBound
4039
) {
41-
context.buildChildren(of: element, defaultSlotFor: "slider")
42-
context.buildChildren(of: element, withTagName: "label", namespace: "slider")
40+
context.buildChildren(of: element, withTagName: "label", namespace: "slider", includeDefaultSlot: true)
4341
} minimumValueLabel: {
4442
context.buildChildren(of: element, withTagName: "minimum-value-label", namespace: "slider")
4543
} maximumValueLabel: {

Sources/LiveViewNative/Views/Images/AsyncImage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ struct AsyncImage<R: CustomRegistry>: View {
2525
case .failure(let error):
2626
SwiftUI.Text(error.localizedDescription)
2727
case .empty:
28-
ProgressView().progressViewStyle(.circular)
28+
SwiftUI.ProgressView().progressViewStyle(.circular)
2929
@unknown default:
3030
EmptyView()
3131
}

Sources/LiveViewNative/Views/Text Input and Output/Text.swift

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,6 @@
77

88
import SwiftUI
99

10-
/// A formatter that parses ISO8601 dates as produced by Elixir's `DateTime`.
11-
fileprivate let dateTimeFormatter: ISO8601DateFormatter = {
12-
let formatter = ISO8601DateFormatter()
13-
formatter.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds]
14-
return formatter
15-
}()
16-
17-
/// A formatter that parses ISO8601 dates as produced by Elixir's `Date`.
18-
fileprivate let dateFormatter: DateFormatter = {
19-
let formatter = DateFormatter()
20-
formatter.dateFormat = "yyyy-MM-dd"
21-
return formatter
22-
}()
23-
2410
struct Text<R: CustomRegistry>: View {
2511
let context: LiveContext<R>
2612

@@ -50,7 +36,7 @@ struct Text<R: CustomRegistry>: View {
5036
}
5137

5238
private func formatDate(_ date: String) -> Date? {
53-
dateTimeFormatter.date(from: date) ?? dateFormatter.date(from: date)
39+
try? ElixirDateParseStrategy().parse(date)
5440
}
5541

5642
private var text: SwiftUI.Text {
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// ProgressViewTests.swift
3+
//
4+
//
5+
// Created by Carson Katri on 1/17/23.
6+
//
7+
8+
import XCTest
9+
import SwiftUI
10+
@testable import LiveViewNative
11+
12+
@MainActor
13+
final class ProgressViewTests: XCTestCase {
14+
func testValue() throws {
15+
try assertMatch(#"<progressview value="0.5" />"#) {
16+
ProgressView(value: 0.5)
17+
}
18+
}
19+
20+
func testTotal() throws {
21+
try assertMatch(#"<progressview value="2.5" total="5" />"#) {
22+
ProgressView(value: 0.5, total: 5)
23+
}
24+
}
25+
}

0 commit comments

Comments
 (0)