Skip to content

Commit faacea1

Browse files
authored
Merge pull request #96 from AvdLee/Roadmap-design-improvements
Roadmap design improvements
2 parents a2035bf + 0e944c2 commit faacea1

File tree

6 files changed

+110
-82
lines changed

6 files changed

+110
-82
lines changed

Example/RoadmapExample/RoadmapExample/ContentView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import SwiftUI
1111
struct ContentView: View {
1212
let configuration = RoadmapConfiguration(
1313
sidetrackRoadmapId: "669827fe83191f8a3a802b4d",
14+
style: RoadmapTemplate.standard.style,
1415
allowVotes: true,
1516
allowSearching: true,
1617
allowsFilterByStatus: true
@@ -53,7 +54,7 @@ struct ContentView: View {
5354
.toolbar {
5455
ToolbarItem {
5556
Link(destination: URL(string: "https://github.com/AvdLee/Roadmap")!) {
56-
Image(systemName: "questionmark.app.fill")
57+
Image(systemName: "questionmark")
5758
.symbolRenderingMode(.hierarchical)
5859
}
5960
}

Sources/Roadmap/RoadmapFeatureView.swift

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import SwiftUI
1010
struct RoadmapFeatureView: View {
1111
@Environment(\.dynamicTypeSize) var typeSize
1212
@State var viewModel: RoadmapFeatureViewModel
13-
13+
1414
var body: some View {
1515
ZStack{
1616
if typeSize.isAccessibilitySize {
@@ -20,35 +20,36 @@ struct RoadmapFeatureView: View {
2020
}
2121
}
2222
.background(viewModel.configuration.style.cellBackground)
23-
.clipShape(RoundedRectangle(cornerRadius: viewModel.configuration.style.radius, style: .continuous))
23+
.clipShape(.rect(cornerRadius: viewModel.configuration.style.cellRadius, style: .continuous))
2424
.task {
2525
await viewModel.getCurrentVotes(firstLoad: true)
2626
}
2727

2828
}
2929

3030
var horizontalCell : some View {
31-
HStack {
31+
HStack(alignment: .top) {
3232
VStack(alignment: .leading, spacing: 8) {
3333
Text(viewModel.feature.localizedFeatureTitle)
3434
.font(viewModel.configuration.style.titleFont)
35+
.accessibilityHeading(.h2)
3536

3637
let description = viewModel.feature.localizedFeatureDescription
3738
if !description.isEmpty {
3839
Text(description)
3940
.font(viewModel.configuration.style.numberFont)
4041
.foregroundColor(Color.secondary)
4142
}
42-
43+
4344
if let localizedStatus = viewModel.feature.localizedFeatureStatus {
4445
let status = viewModel.feature.unlocalizedFeatureStatus
4546
Text(localizedStatus)
4647
.padding(6)
4748
.background(viewModel.configuration.style.statusTintColor(status).opacity(0.1))
4849
.foregroundColor(viewModel.configuration.style.statusTintColor(status))
49-
.cornerRadius(5)
50+
.clipShape(.rect(cornerRadius: viewModel.configuration.style.radius))
5051
.overlay(
51-
RoundedRectangle(cornerRadius: 5)
52+
RoundedRectangle(cornerRadius: viewModel.configuration.style.radius)
5253
.stroke(viewModel.configuration.style.statusTintColor(status).opacity(0.15), lineWidth: 1)
5354
)
5455
.font(viewModel.configuration.style.statusFont)
@@ -89,7 +90,7 @@ struct RoadmapFeatureView: View {
8990
.font(viewModel.configuration.style.numberFont)
9091
.foregroundColor(Color.secondary)
9192
}
92-
93+
9394
if let localizedStatus = viewModel.feature.localizedFeatureStatus {
9495
let status = viewModel.feature.unlocalizedFeatureStatus
9596
Text(localizedStatus)
@@ -109,8 +110,6 @@ struct RoadmapFeatureView: View {
109110
}
110111
}
111112

112-
struct RoadmapFeatureView_Previews: PreviewProvider {
113-
static var previews: some View {
114-
RoadmapFeatureView(viewModel: .init(feature: .sample(), configuration: .sampleURL()))
115-
}
113+
#Preview {
114+
RoadmapFeatureView(viewModel: .init(feature: .sample(), configuration: .sampleURL()))
116115
}

Sources/Roadmap/RoadmapView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ public struct RoadmapView<Header: View, Footer: View>: View {
6666
RoadmapFeatureView(viewModel: viewModel.featureViewModel(for: feature))
6767
.macOSListRowSeparatorHidden()
6868
.listRowBackground(Color.clear)
69+
#if os(iOS)
70+
.listRowInsets(.init(top: 0, leading: 16, bottom: 16, trailing: 16))
71+
#endif
6972
}
7073
footer
7174
}

Sources/Roadmap/RoadmapVoteButton.swift

Lines changed: 86 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -9,85 +9,41 @@ import SwiftUI
99

1010
struct RoadmapVoteButton: View {
1111
@State var viewModel: RoadmapFeatureViewModel
12-
@Environment(\.dynamicTypeSize) var typeSize
12+
@Environment(\.dynamicTypeSize) private var typeSize
1313

1414
@State private var isHovering = false
1515
@State private var showNumber = false
1616
@State private var hasVoted = false
1717

1818
var body: some View {
1919
Button {
20-
if viewModel.canVote {
21-
Task {
22-
if !viewModel.feature.hasVoted {
23-
await viewModel.vote()
24-
} else {
25-
await viewModel.unvote()
26-
}
27-
#if os(iOS)
28-
let haptic = UIImpactFeedbackGenerator(style: .soft)
29-
haptic.impactOccurred()
30-
#endif
20+
guard viewModel.canVote else { return }
21+
Task {
22+
if !viewModel.feature.hasVoted {
23+
await viewModel.vote()
24+
announceAccessibility("Added vote")
25+
} else {
26+
await viewModel.unvote()
27+
announceAccessibility("Removed vote")
3128
}
29+
#if os(iOS)
30+
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
31+
#endif
3232
}
3333
} label: {
3434
ZStack {
3535
if typeSize.isAccessibilitySize {
3636
HStack(spacing: isHovering ? 2 : 0) {
37-
if viewModel.canVote {
38-
if !viewModel.feature.hasVoted {
39-
viewModel.configuration.style.upvoteIcon
40-
.foregroundColor(hasVoted ? viewModel.configuration.style.selectedForegroundColor : viewModel.configuration.style.tintColor)
41-
.imageScale(.large)
42-
.font(Font.system(size: 17, weight: .medium))
43-
.frame(maxWidth: 24, maxHeight: 24)
44-
} else {
45-
viewModel.configuration.style.unvoteIcon
46-
.foregroundColor(hasVoted ? viewModel.configuration.style.selectedForegroundColor : viewModel.configuration.style.tintColor)
47-
.imageScale(.large)
48-
.font(Font.system(size: 17, weight: .medium))
49-
.frame(maxWidth: 24, maxHeight: 24)
50-
}
51-
}
52-
53-
if showNumber {
54-
Text("\(viewModel.voteCount)")
55-
.lineLimit(1)
56-
.foregroundColor(hasVoted ? viewModel.configuration.style.selectedForegroundColor : viewModel.configuration.style.tintColor)
57-
.minimumScaleFactor(0.5)
58-
.font(viewModel.configuration.style.numberFont)
59-
}
37+
icon.accessibilityHidden(true)
38+
if showNumber { numberText }
6039
}
6140
.padding(viewModel.configuration.style.radius)
6241
.frame(minHeight: 64)
6342
.background(backgroundView)
6443
} else {
6544
VStack(spacing: isHovering ? 6 : 4) {
66-
if viewModel.canVote {
67-
if !viewModel.feature.hasVoted {
68-
viewModel.configuration.style.upvoteIcon
69-
.foregroundColor(hasVoted ? viewModel.configuration.style.selectedForegroundColor : viewModel.configuration.style.tintColor)
70-
.imageScale(.large)
71-
.font(viewModel.configuration.style.numberFont)
72-
.frame(maxWidth: 20, maxHeight: 20)
73-
.minimumScaleFactor(0.75)
74-
} else {
75-
viewModel.configuration.style.unvoteIcon
76-
.foregroundColor(hasVoted ? viewModel.configuration.style.selectedForegroundColor : viewModel.configuration.style.tintColor)
77-
.imageScale(.large)
78-
.font(viewModel.configuration.style.numberFont)
79-
.frame(maxWidth: 20, maxHeight: 20)
80-
.minimumScaleFactor(0.75)
81-
}
82-
}
83-
84-
if showNumber {
85-
Text("\(viewModel.voteCount)")
86-
.lineLimit(1)
87-
.foregroundColor(hasVoted ? viewModel.configuration.style.selectedForegroundColor : viewModel.configuration.style.tintColor)
88-
.font(viewModel.configuration.style.numberFont)
89-
.minimumScaleFactor(0.9)
90-
}
45+
icon.accessibilityHidden(true)
46+
if showNumber { numberText }
9147
}
9248
.frame(minWidth: 56)
9349
.frame(height: 64)
@@ -102,39 +58,86 @@ struct RoadmapVoteButton: View {
10258
.buttonBorderShape(.roundedRectangle(radius: viewModel.configuration.style.radius))
10359
.clipShape(RoundedRectangle(cornerRadius: viewModel.configuration.style.radius, style: .continuous))
10460
#endif
61+
.accessibilityElement(children: .ignore)
62+
.accessibilityLabel(Text("Vote"))
63+
.accessibilityValue(Text(accessibilityValue))
64+
.accessibilityHint(Text(viewModel.canVote
65+
? (hasVoted
66+
? "Double-tap to remove your vote for \(viewModel.feature.localizedFeatureTitle)"
67+
: "Double-tap to vote for \(viewModel.feature.localizedFeatureTitle)")
68+
: "Voting unavailable"))
69+
.accessibilityAddTraits(.isButton)
70+
.accessibilityAddTraits(hasVoted ? .isSelected : [])
71+
.accessibilityRespondsToUserInteraction(viewModel.canVote)
72+
.accessibilityShowsLargeContentViewer()
73+
.accessibilityIdentifier("roadmap_vote_button")
74+
.disabled(!viewModel.canVote)
10575
.onChange(of: viewModel.voteCount) { _, newCount in
10676
if newCount > 0 {
107-
withAnimation(.spring(response: 0.45, dampingFraction: 0.4, blendDuration: 0)) {
77+
withAnimation(.spring(response: 0.45, dampingFraction: 0.4)) {
10878
showNumber = true
10979
}
11080
}
11181
}
11282
.onChange(of: viewModel.feature.hasVoted) { _, newVote in
113-
withAnimation(.spring(response: 0.45, dampingFraction: 0.4, blendDuration: 0)) {
83+
withAnimation(.spring(response: 0.45, dampingFraction: 0.4)) {
11484
hasVoted = newVote
11585
}
11686
}
11787
.onHover { newHover in
11888
if viewModel.canVote && !hasVoted {
119-
withAnimation(.spring(response: 0.4, dampingFraction: 0.7, blendDuration: 0)) {
89+
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
12090
isHovering = newHover
12191
}
12292
}
12393
}
12494
.onAppear {
12595
showNumber = viewModel.voteCount > 0
126-
withAnimation(.spring(response: 0.45, dampingFraction: 0.4, blendDuration: 0)) {
96+
withAnimation(.spring(response: 0.45, dampingFraction: 0.4)) {
12797
hasVoted = viewModel.feature.hasVoted
12898
}
12999
}
130-
.accessibilityHint(viewModel.canVote ? !viewModel.feature.hasVoted ? Text("Vote for \(viewModel.feature.localizedFeatureTitle)") : Text("Remove vote for \(viewModel.feature.localizedFeatureTitle)") : Text(""))
131-
.help(viewModel.canVote ? !viewModel.feature.hasVoted ? Text("Vote for \(viewModel.feature.localizedFeatureTitle)") : Text("Remove vote for \(viewModel.feature.localizedFeatureTitle)") : Text(""))
100+
.help(viewModel.canVote
101+
? (!viewModel.feature.hasVoted
102+
? Text("Vote for \(viewModel.feature.localizedFeatureTitle)")
103+
: Text("Remove vote for \(viewModel.feature.localizedFeatureTitle)"))
104+
: Text("Voting unavailable"))
132105
.animateAccessible()
133-
.accessibilityShowsLargeContentViewer()
106+
}
107+
108+
// MARK: - Pieces
109+
110+
private var icon: some View {
111+
Group {
112+
if viewModel.canVote {
113+
if !viewModel.feature.hasVoted {
114+
viewModel.configuration.style.upvoteIcon
115+
} else {
116+
viewModel.configuration.style.unvoteIcon
117+
}
118+
}
119+
}
120+
.foregroundColor(hasVoted ? viewModel.configuration.style.selectedForegroundColor
121+
: viewModel.configuration.style.tintColor)
122+
.imageScale(.large)
123+
.font(typeSize.isAccessibilitySize ? .system(size: 17, weight: .medium)
124+
: viewModel.configuration.style.numberFont)
125+
.frame(maxWidth: typeSize.isAccessibilitySize ? 24 : 20,
126+
maxHeight: typeSize.isAccessibilitySize ? 24 : 20)
127+
.minimumScaleFactor(0.75)
128+
}
129+
130+
private var numberText: some View {
131+
Text("\(viewModel.voteCount)")
132+
.lineLimit(1)
133+
.foregroundColor(hasVoted ? viewModel.configuration.style.selectedForegroundColor
134+
: viewModel.configuration.style.tintColor)
135+
.font(viewModel.configuration.style.numberFont)
136+
.minimumScaleFactor(typeSize.isAccessibilitySize ? 0.5 : 0.9)
134137
}
135138

136139
@ViewBuilder
137-
var overlayBorder: some View {
140+
private var overlayBorder: some View {
138141
if isHovering {
139142
RoundedRectangle(cornerRadius: viewModel.configuration.style.radius, style: .continuous)
140143
.stroke(viewModel.configuration.style.tintColor, lineWidth: 1)
@@ -146,4 +149,19 @@ struct RoadmapVoteButton: View {
146149
.opacity(hasVoted ? 1 : 0.1)
147150
.clipShape(RoundedRectangle(cornerRadius: viewModel.configuration.style.radius, style: .continuous))
148151
}
152+
153+
// MARK: - A11y helpers
154+
155+
private var accessibilityValue: String {
156+
let c = viewModel.voteCount
157+
if c == 0 { return "0 votes" }
158+
if c == 1 { return "1 vote" }
159+
return "\(c) votes"
160+
}
161+
162+
private func announceAccessibility(_ text: String) {
163+
#if os(iOS)
164+
UIAccessibility.post(notification: .announcement, argument: text)
165+
#endif
166+
}
149167
}

Sources/Roadmap/Styling/RoadmapStyle.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ public struct RoadmapStyle: Sendable {
2929
/// The corner radius for the upvote button
3030
public var radius: CGFloat
3131

32+
/// The corner radius for the cell
33+
public var cellRadius: CGFloat
34+
3235
/// The backgroundColor of each cell
3336
public var cellColor: Color?
3437

@@ -61,6 +64,7 @@ public struct RoadmapStyle: Sendable {
6164
statusFont: Font,
6265
statusTintColor: @escaping @Sendable (String) -> Color = { _ in Color.primary },
6366
cornerRadius: CGFloat,
67+
cellRadius: CGFloat? = nil,
6468
cellColor: Color? = nil,
6569
cellMaterial: Material? = nil,
6670
selectedColor: Color = .white,
@@ -73,6 +77,7 @@ public struct RoadmapStyle: Sendable {
7377
self.statusFont = statusFont
7478
self.statusTintColor = statusTintColor
7579
self.radius = cornerRadius
80+
self.cellRadius = cellRadius ?? cornerRadius
7681
self.cellColor = cellColor
7782
self.cellMaterial = cellMaterial
7883
self.selectedForegroundColor = selectedColor

Sources/Roadmap/Styling/RoadmapTemplates.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@ public enum RoadmapTemplate: CaseIterable {
2020
titleFont: self.titleFont,
2121
numberFont: self.numberFont,
2222
statusFont: self.captionFont,
23-
cornerRadius: 10)
23+
cornerRadius: 10,
24+
cellRadius: 18)
2425
case .playful:
2526
return RoadmapStyle(upvoteIcon: Image(systemName: "arrow.up"),
2627
unvoteIcon: Image(systemName: "arrow.down"),
2728
titleFont: self.titleFont,
2829
numberFont: self.numberFont,
2930
statusFont: self.captionFont,
30-
cornerRadius: 15)
31+
cornerRadius: 18,
32+
cellRadius: 26)
3133
case .classy:
3234
return RoadmapStyle(upvoteIcon: Image(systemName: "chevron.up"),
3335
unvoteIcon: Image(systemName: "chevron.down"),

0 commit comments

Comments
 (0)