Add short transitions for navigation, answers and progress on every platform.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-08 21:13:32 +02:00
commit 59d40c886a
32 changed files with 359 additions and 121 deletions

View file

@ -110,12 +110,12 @@ final class AppModel: ObservableObject {
}
func setTheme(_ id: ThemeId) {
themeId = id
withAnimation(Motion.settle) { themeId = id }
progress.themeId = id
}
func applyMode(_ m: ColorMode) {
mode = m
withAnimation(Motion.settle) { mode = m }
progress.mode = m
}
@ -285,7 +285,7 @@ final class AppModel: ObservableObject {
func dismissChangelog() {
progress.seenCommit = AppConfig.commit
showChangelog = false
withAnimation(Motion.settle) { showChangelog = false }
}
}

View file

@ -1,5 +1,47 @@
import SwiftUI
enum Motion {
static let press = Animation.easeOut(duration: 0.16)
static let settle = Animation.easeOut(duration: 0.28)
static let feedback = Animation.spring(response: 0.32, dampingFraction: 0.82)
}
/// Slight press-in for custom cards and map nodes. System buttons keep their own style.
struct PressScaleStyle: ButtonStyle {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(reduceMotion || !configuration.isPressed ? 1 : 0.98)
.animation(reduceMotion ? nil : Motion.press, value: configuration.isPressed)
}
}
private struct SoftAppear: ViewModifier {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var shown = false
func body(content: Content) -> some View {
content
.opacity(reduceMotion || shown ? 1 : 0)
.offset(y: reduceMotion || shown ? 0 : 8)
.onAppear {
guard !reduceMotion else {
shown = true
return
}
withAnimation(Motion.settle) { shown = true }
}
}
}
extension View {
/// One short fade-in. Use on a landing surface, not on every pushed screen.
func softAppear() -> some View {
modifier(SoftAppear())
}
}
#if os(macOS)
enum MacChrome {
static let pageInset: CGFloat = 28
@ -265,21 +307,27 @@ struct FeedbackLine: View {
let text: String
let kind: String
let palette: Palette
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
if !text.isEmpty {
Label {
Text(text)
} icon: {
Image(systemName: kind == "ok" ? "checkmark.circle.fill" : kind == "warn" ? "exclamationmark.triangle.fill" : "xmark.circle.fill")
Group {
if !text.isEmpty {
Label {
Text(text)
} icon: {
Image(systemName: kind == "ok" ? "checkmark.circle.fill" : kind == "warn" ? "exclamationmark.triangle.fill" : "xmark.circle.fill")
}
#if os(macOS)
.font(.body.weight(.semibold))
#else
.font(.subheadline.weight(.semibold))
#endif
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .leading)))
}
#if os(macOS)
.font(.body.weight(.semibold))
#else
.font(.subheadline.weight(.semibold))
#endif
.foregroundStyle(kind == "ok" ? palette.success : kind == "warn" ? palette.warning : palette.error)
}
.animation(reduceMotion ? nil : Motion.feedback, value: text)
.animation(reduceMotion ? nil : Motion.feedback, value: kind)
}
}
@ -288,17 +336,23 @@ struct Meaning: View {
let palette: Palette
let visible: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
if visible && !text.isEmpty {
Text(text)
#if os(macOS)
.font(.body)
#else
.font(.footnote)
#endif
.foregroundStyle(.secondary)
.padding(.bottom, 4)
Group {
if visible && !text.isEmpty {
Text(text)
#if os(macOS)
.font(.body)
#else
.font(.footnote)
#endif
.foregroundStyle(.secondary)
.padding(.bottom, 4)
.transition(.opacity)
}
}
.animation(reduceMotion ? nil : Motion.settle, value: visible)
}
}
@ -307,6 +361,7 @@ struct WordField: View {
var placeholder: String
var mark: Bool?
var palette: Palette?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
HStack(spacing: 8) {
@ -332,6 +387,7 @@ struct WordField: View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(border, lineWidth: mark == nil ? 0.5 : 1.5)
)
.animation(reduceMotion ? nil : Motion.settle, value: mark)
}
private var border: Color {
@ -426,6 +482,7 @@ struct OptionChip: View {
let selected: Bool
let mark: Bool?
var action: () -> Void
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Button(action: action) {
@ -453,6 +510,8 @@ struct OptionChip: View {
.controlSize(.large)
#endif
.padding(.bottom, 4)
.animation(reduceMotion ? nil : Motion.settle, value: mark)
.animation(reduceMotion ? nil : Motion.press, value: selected)
}
private var symbol: String {

View file

@ -35,10 +35,11 @@ struct ExerciseScaffold<Content: View>: View {
if !feedback.isEmpty {
FeedbackLine(text: feedback, kind: kind, palette: p)
.frame(maxWidth: .infinity, alignment: .leading)
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .leading)))
}
}
}
.animation(.default, value: feedback)
.animation(Motion.feedback, value: feedback)
}
}

View file

@ -330,7 +330,7 @@ struct WordPicker: View {
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
.listStyle(.plain)
@ -1224,9 +1224,10 @@ private struct VlsmEx: View {
.padding(.horizontal)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.transition(.opacity.combined(with: .scale(scale: 0.96, anchor: .leading)))
}
}
.animation(.default, value: fb.0)
.animation(Motion.feedback, value: fb.0)
}
private func maybeFinish() {
@ -1359,7 +1360,7 @@ struct LitQuizScreen: View {
}) { EmptyView() }
}
}
.animation(.default, value: answered)
.animation(Motion.settle, value: answered)
}
}

View file

@ -215,6 +215,7 @@ struct PathMap: View {
#else
.frame(minHeight: 420)
#endif
.softAppear()
}
@ViewBuilder
@ -321,7 +322,7 @@ struct PathMap: View {
if let dest = node.destination, !node.locked {
NavigationLink(value: dest) { content }
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
} else {
content.opacity(node.locked ? 0.55 : 1)
}

View file

@ -8,9 +8,9 @@ struct PracticeHome: View {
var body: some View {
#if os(macOS)
macHome
macHome.softAppear()
#else
iosHome
iosHome.softAppear()
#endif
}
@ -46,6 +46,7 @@ struct PracticeHome: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
ProgressView(value: pct)
.animation(Motion.settle, value: pct)
Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
.font(.subheadline)
.foregroundStyle(.secondary)
@ -91,7 +92,7 @@ struct PracticeHome: View {
NavigationLink(value: dest) {
subjectTileBody(s, open: true, progress: progress, part: part)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
} else {
subjectTileBody(s, open: false, progress: 0, part: part)
}
@ -108,6 +109,7 @@ struct PracticeHome: View {
Spacer(minLength: 0)
if open && part.totalEx > 0 {
ProgressView(value: progress)
.animation(Motion.settle, value: progress)
Text(vm.fmt("stats_ex_fmt", part.doneEx, part.totalEx))
.font(.subheadline)
.foregroundStyle(.secondary)
@ -146,6 +148,7 @@ struct PracticeHome: View {
.font(.subheadline)
.foregroundStyle(.secondary)
ProgressView(value: pct)
.animation(Motion.settle, value: pct)
Text(vm.fmt("stats_ex_fmt", sum.doneEx, sum.totalEx))
.font(.footnote)
.foregroundStyle(.secondary)
@ -415,7 +418,7 @@ struct SettingsScreen: View {
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
.softSurface(cornerRadius: 16)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
}
@ -476,6 +479,7 @@ struct StatsScreen: View {
stat(vm.tr("stats_units_label"), vm.fmt("stats_units_fmt", sum.doneUnits, sum.openUnits))
}
ProgressView(value: pct)
.animation(Motion.settle, value: pct)
}
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
@ -505,6 +509,7 @@ struct StatsScreen: View {
}
if open && part.totalEx > 0 {
ProgressView(value: Double(part.doneEx) / Double(part.totalEx))
.animation(Motion.settle, value: part.doneEx)
}
}
} icon: {
@ -773,7 +778,7 @@ struct SearchScreen: View {
.frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
.softSurface(cornerRadius: 16)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
.disabled(hit.locked)
}
}
@ -848,7 +853,7 @@ struct SearchScreen: View {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
.accessibilityLabel("Clear")
}
}
@ -1205,7 +1210,7 @@ struct NetYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("net_years_title"))
.navigationSubtitle(vm.tr("net_years_sub"))
@ -1246,7 +1251,7 @@ struct HwYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Technické vybavení"))
.navigationSubtitle(vm.tr("hw_years_sub"))
@ -1288,7 +1293,7 @@ struct DeHomeScreen: View {
NavigationLink(value: rows[i].3) {
MacLinkCard(title: vm.tr(rows[i].1), subtitle: vm.tr(rows[i].2), badge: rows[i].0)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Deutsch"))
.navigationSubtitle(vm.tr("de_home_sub"))
@ -1324,7 +1329,7 @@ struct EnYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("English"))
.navigationSubtitle(vm.tr("en_years_sub"))
@ -1358,7 +1363,7 @@ struct OnYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Občanská nauka"))
.navigationSubtitle(vm.tr("on_years_sub"))
@ -1472,9 +1477,14 @@ struct SlidesScreen: View {
#else
if slides.indices.contains(idx) {
slidePage(slides[idx])
.id(idx)
.transition(.opacity)
}
#endif
}
#if os(macOS)
.animation(Motion.settle, value: idx)
#endif
.detailScreen(title, subtitle: subtitle)
.glassBottomBar {
VStack(spacing: 10) {
@ -1485,7 +1495,7 @@ struct SlidesScreen: View {
.frame(width: i == idx ? 18 : 6, height: 6)
}
}
.animation(.default, value: idx)
.animation(Motion.settle, value: idx)
HStack(spacing: 10) {
if idx > 0 {
Button {
@ -1668,7 +1678,7 @@ struct LitYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Literatura"))
.navigationSubtitle(vm.tr("lit_years_sub"))
@ -1710,7 +1720,7 @@ struct MatYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: years[i].3)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Matematika"))
.navigationSubtitle(vm.tr("mat_years_sub"))
@ -1754,7 +1764,7 @@ struct FyzYearsScreen: View {
NavigationLink(value: years[i].2) {
MacLinkCard(title: vm.tr(years[i].0), subtitle: vm.tr(years[i].1), badge: "\(i + 1).")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
.navigationTitle(vm.tr("Fyzika"))
.navigationSubtitle(vm.tr("fyz_years_sub"))
@ -1790,12 +1800,12 @@ struct SciMapScreen: View {
NavigationLink(value: Route.chemMap) {
MacLinkCard(title: vm.tr("Chemie"), subtitle: vm.tr("chem_sub"), systemImage: "flask.fill")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
} else {
NavigationLink(value: Route.bioMap) {
MacLinkCard(title: vm.tr("Biologie"), subtitle: vm.tr("bio_sub"), systemImage: "leaf.fill")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
.navigationTitle(vm.tr("Základy Přírodopisných věd"))
@ -1835,17 +1845,17 @@ struct CzechMapScreen: View {
NavigationLink(value: Route.mluvnice) {
MacLinkCard(title: vm.tr("Mluvnice"), systemImage: "textformat")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
case 2:
NavigationLink(value: Route.readingList) {
MacLinkCard(title: vm.tr("Maturitní četba"), systemImage: "books.vertical")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
default:
NavigationLink(value: Route.litYears) {
MacLinkCard(title: vm.tr("Literatura"), systemImage: "book.closed")
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
.navigationTitle(vm.tr("Český jazyk a literatura"))
@ -1932,7 +1942,7 @@ struct BookListScreen: View {
.frame(maxWidth: .infinity, minHeight: 168, alignment: .topLeading)
.softSurface(cornerRadius: 18)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
.padding(MacChrome.pageInset)
@ -2070,7 +2080,7 @@ struct BookScreen: View {
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
if hasPlot {
NavigationLink(value: Route.bookPlot(id)) {
@ -2082,7 +2092,7 @@ struct BookScreen: View {
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
}
#else
@ -2096,7 +2106,7 @@ struct BookScreen: View {
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
if hasPlot {
NavigationLink(value: Route.bookPlot(id)) {
@ -2108,7 +2118,7 @@ struct BookScreen: View {
doneLabel: vm.tr("book_done")
)
}
.buttonStyle(.plain)
.buttonStyle(PressScaleStyle())
}
#endif
}