Как выглядит и ведёт себя Круг
Одна страница — и инструкция, и живой пример. Цвета здесь берутся из того же файла, что и в приложении, а под каждым компонентом лежит его настоящий код. Если на этой странице что-то не так, значит, в приложении тоже.
Круг — дневник еды, в котором запись занимает секунды: одна строка «название количество», одна подсказка, Enter. Всё остальное на экране подчинено этой строке. Дизайн-система описывает, из чего она и окружение собраны, и какие правила нельзя нарушать, чтобы приложение оставалось быстрым и ощущалось родным на iPhone.
Четыре критерия
Из goal.md: быстрота использования, эстетическое удовольствие, простота поиска, надёжность без интернета. Любое решение проверяется этими четырьмя.
Правила, которые уже приняты
textSecondary, textTertiary), как в самом iOS, а не более тонким начертанием.Цвет
Две темы, одни имена. Тёмная — не инверсия светлой, а своя палитра по образцу iOS: почти чёрный фон, поверхности #1C1C1E, притушенный второстепенный текст, оттенки макросов чуть светлее, чтобы читались на чёрном. Переключите тему кнопкой в шапке — вся страница перекрасится теми же токенами.
| Токен | Светлая | Тёмная | Где |
|---|---|---|---|
bg | #F5F5F7 | #000000 | Фон экрана |
surface | #FFFFFF | #1C1C1E | Поверхность: плитки, шторки, строки списка |
surfaceMuted | #EDEDF0 | #2C2C2E | Приглушённая поверхность: дорожка пилюли |
text | #111114 | #FFFFFF | Основной текст |
textSecondary | #696971 | #9A9A9E | Второстепенный текст: подписи, статусы |
textTertiary | #8D8D97 | #6B6B70 | Третьестепенный: подсказка в строке, плейсхолдер, бренд в списке |
accent | #3B6CF6 | #6A99FF | Акцент: ссылки, активный микрофон, выбранный день в меню |
kcal | #5DB523 | #92CF2C | Калории: внешнее кольцо, буква К, число записи |
protein | #F2626C | #FF7A82 | Белки: второе кольцо, буква Б |
fat | #F2A63B | #FFB454 | Жиры: третье кольцо, буква Ж |
carbs | #4E8AF4 | #6B9FFF | Углеводы: внутреннее кольцо, буква У |
kcalInk | #519D1E | #92CF2C | Калории текстом: число записи, буква К, Ккал в центре колец, плитка |
proteinInk | #F15661 | #FF7A82 | Белки текстом |
fatInk | #C97B0D | #FFB454 | Жиры текстом |
carbsInk | #4E8AF4 | #6B9FFF | Углеводы текстом |
Palette.track). Отдельного «серого» для дорожек нет: пустое кольцо читается как «то же кольцо, ещё не заполненное».kcal) для колец и бегунка и чернила (kcalInk) для той же величины, написанной текстом. В тёмной теме это один цвет; в светлой заливки слишком бледны для текста (зелёный на фоне — 2,4:1), и чернила на тон глубже — ровно до 3:1, порога для крупного текста: цветной текст в приложении всегда крупный (от 20 pt) или жирная буква. Глубже — читается грязно. Обычный серый текст — не ниже 4,5:1; это проверяет тест ContrastTests, и падает, если кто-то сделает цвет слабее.surface) отделяет группу от фона, не спорит с ним: в тёмной теме #1C1C1E на чёрном, в светлой белая на #F5F5F7. Одна и та же поверхность у плиток статистики, строк профиля и меню, карточек блюда; системные, более светлые плашки списков не используются.Код · UI/Palette.swift
enum Palette {
static let bg = dynamic(0xF5F5F7, 0x000000)
static let surface = dynamic(0xFFFFFF, 0x1C1C1E)
static let surfaceMuted = dynamic(0xEDEDF0, 0x2C2C2E)
static let text = dynamic(0x111114, 0xFFFFFF)
static let textSecondary = dynamic(0x696971, 0x9A9A9E)
static let textTertiary = dynamic(0x8D8D97, 0x6B6B70)
static let accent = dynamic(0x3B6CF6, 0x6A99FF)
// The four values: a fill (rings, the pill's thumb) and an ink for the
// same value written as text. In the dark theme one colour does both.
// In the light theme the fills are too pale to read (kcal green on
// #F5F5F7 is 2.4:1), so the inks are a shade deeper — just enough for
// 3:1, the bar for large text, which is what this text always is
// (20 pt and up, or a bold letter). Deeper than that read as muddy to
// the founder (KrugTests/ContrastTests holds the line).
static let kcal = dynamic(0x5DB523, 0x92CF2C)
static let protein = dynamic(0xF2626C, 0xFF7A82)
static let fat = dynamic(0xF2A63B, 0xFFB454)
static let carbs = dynamic(0x4E8AF4, 0x6B9FFF)
static let kcalInk = dynamic(0x519D1E, 0x92CF2C)
static let proteinInk = dynamic(0xF15661, 0xFF7A82)
static let fatInk = dynamic(0xC97B0D, 0xFFB454)
static let carbsInk = dynamic(0x4E8AF4, 0x6B9FFF)
/// Over the camera's viewfinder, whatever the theme: it is always dark.
static let onCamera = Color.white
static let cameraBackdrop = Color.black
/// The shadow a stadium ring's tip casts on the ring under it when it
/// laps the track (over 100%).
static let ringShadow = Color.black.opacity(0.45)
/// A ring's unfilled track: its own hue, dimmed.
static func track(_ ring: Color, dark: Bool) -> Color { ring.opacity(dark ? 0.28 : 0.2) }
private static func dynamic(_ light: UInt32, _ dark: UInt32) -> Color {
Color(UIColor { $0.userInterfaceStyle == .dark ? uiColor(dark) : uiColor(light) })
}
private static func uiColor(_ hex: UInt32) -> UIColor {
UIColor(
red: CGFloat((hex >> 16) & 0xFF) / 255,
green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255,
alpha: 1
)
}
}Шрифт
SF Pro — системный шрифт iPhone, без вшитых файлов. Приложение использует только системные текстовые стили, поэтому весь интерфейс масштабируется с настройкой «Размер текста» на телефоне. Ниже — размеры при стандартном значении; при крупном тексте все они растут пропорционально, и верстка обязана это выдерживать (проверяется UI-тестами с категорией Accessibility L).
Где какой
| Стиль | Роль |
|---|---|
title3 regular | Голос дневника: строки записей, строка ввода, подсказка, ингредиенты блюда, поля профиля и шторки |
title3 semibold | Заголовок дня в шапке («Сегодня») |
title2 semibold / medium | Буква К/Б/Ж/У в шапке; числа в центре колец |
body | Строки настроек, списков, меню, пилюля Д/Н/М (semibold) |
subheadline | Все подписи и статусы под строкой, заголовки групп в шторке, бренд в списке совпадений — вторым цветом |
largeTitle, title | Крупные числа: средние калории в статистике, значения в плитках, поля КБЖУ в шторке |
monospacedDigit(), чтобы колонка не дрожала.Отступы и форма
Шкала небольшая и целиком взята из того, как свёрстаны экраны. Горизонтальное поле дневника — 24, оно же у ингредиентов блюда и шторки; экран статистики и списки — 16; панель колец — 12.
| Значение | Где |
|---|---|
| 24 / 5 | Строка дневника: поле слева-справа 24, сверху-снизу 5. Список начинается через 8 от шапки |
| 16 | Между названием записи и числом справа; между строкой и кнопкой «очистить» — 12 |
| 12 | Подпись-статус под строкой ввода отступает на 12; плашка «Удалено» — 16 от краёв экрана, 8 от низа |
| 14 | Между пилюлей и кольцами; внутри плитки по вертикали (по горизонтали 16) |
| 10 / 6 | Сетка плиток — 10; мини-кольца в календаре — 6; месяцы — 14 |
| 52 | Высота шапки дневника; кнопки в ней занимают всю высоту |
| 212 | Высота стадиона на главном экране |
Скругления и толщины
| Форма | Значение |
|---|---|
| Карточки: плитки статистики, плашки редактора блюда | радиус 20, непрерывное (continuous) скругление |
| Пилюля, плашка, кнопки шторки | капсула (радиус = половина высоты) |
| Стадион на главном | четыре кольца, толщина 13, зазор 3 |
| Кольца статистики | толщина 24, зазор 5 |
| Мини-кольца недели / календаря / месяцев | 3.5 / 1.5 · 3 / 1.5 · 5 / 2.5 |
| Пилюля Д/Н/М | сегмент 46 × 32, внутренний отступ 2 |
Движение
Анимаций мало, и каждая отвечает за одно: показать, что изменилось. Кольца едут от прошлого значения, а не от нуля; список совпадений выезжает, а не вспыхивает; страница дня при возврате к «Сегодня» доезжает за треть секунды. Нажмите на карточку, чтобы проиграть.
Motion.stadiumMotion.ringsMotion.pillMotion.revealMotion.pageMotion.panelMotion.numberОтклик
| Событие | Хаптика |
|---|---|
| Запись сохранена | impact(.light) |
| Enter без подсказки (ничего не записано) | selection |
| Переключение Д/Н/М, К/Б/Ж/У, периода статистики | selection |
| Штрихкод распознан | notification(.success) |
contentTransition(.numericText()): цифры перелистываются, а не подменяются.Компоненты
Слева — как это выглядит, справа — что важно. Примеры нарисованы здесь заново на тех же токенах, а под каждым раскрывается настоящий Swift-код из приложения.
Строка записи
Одна строка — одна запись: название и количество словами, число справа. Число — калории, или белки/жиры/углеводы, если переключить букву в шапке; цвет числа следует за величиной.
Тап по строке — правка на месте: поле ввода встаёт на её место с её же текстом. Долгое нажатие — системное меню «Изменить / Удалить». Ни бренда, ни времени, ни иконок в строке нет.
Код · UI/DiaryView.swift
private func entryLine(_ e: DiaryEntry, current: Bool) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 16) {
Text(e.line(lang: store.lang))
.foregroundStyle(Palette.text)
.frame(maxWidth: .infinity, alignment: .leading)
if e.counted {
Text(metric.value(e))
.monospacedDigit()
.foregroundStyle(metric.color)
.contentTransition(.numericText())
.accessibilityLabel("\(metric.fullName) \(metric.value(e))")
} else {
// No product yet: the numbers are a question, and a tap picks one.
Text(verbatim: "?")
.font(.body.weight(.semibold))
.foregroundStyle(Palette.textSecondary)
.frame(minWidth: 30, minHeight: 30)
.background(Palette.surfaceMuted, in: Circle())
.accessibilityLabel(L("Не посчитано"))
.accessibilityIdentifier("uncounted")
}
}
.font(.title3)
.padding(.horizontal, 24)
.padding(.vertical, 5)
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityHint(current ? L("Изменить") : "")
.onTapGesture { if current { edit(e) } }
.contextMenu {
Button("Изменить", systemImage: "pencil") { edit(e) }
Button("Удалить", systemImage: "trash", role: .destructive) { delete(e) }
}
}Строка ввода с подсказкой
Системное поле ввода, а за ним — серый текст подсказки ровно от того места, где кончается набранное. Подсказка одна и всегда целая: продукт и количество. Enter принимает её целиком.
Если догадка не продолжает набранное (опечатка, другой падеж, не та раскладка), она показывается целиком после стрелки: Enter никогда не запишет то, чего не видно.
Под строкой — только то, чего подсказка сказать не может: «Ищу…», «Ничего не найдено», «Нет сети», и всегда — ссылка на свой продукт. Камера и микрофон стоят в пустой строке и исчезают, когда есть текст; при правке записи их нет.
Код · UI/GhostField.swift
import SwiftUI
/// The input line with one inline "ghost" completion, the way a browser's
/// address bar guesses the rest: `suggestion` is the whole line the app
/// expects ("Творог 5% 150 грамм"), and whatever of it lies past the typed
/// text is drawn grayed-out behind the real, system text field. Enter
/// (the Go key) is how the guess is taken.
struct GhostField: View {
@Binding var text: String
var suggestion: String?
var focus: FocusState<Bool>.Binding
/// Called with the line as submitted (the field's text may not have
/// caught up within the same update).
var onSubmit: (String) -> Void
/// A tap on the grey text — "not this one?" — opens the other matches.
var onGhostTap: (() -> Void)? = nil
/// Where the typed text ends on its first line, in the field's
/// coordinates: a tap to the right of it (or below the first line) is on
/// the ghost, a tap on the typed text itself just moves the caret.
@State private var typedWidth: CGFloat = 0
@State private var lineHeight: CGFloat = 0
/// What's drawn after the typed text: the rest of a continuation, or —
/// for a guess that doesn't continue it (a typo, another case form, the
/// other keyboard layout) — the whole line after an arrow, so Enter never
/// logs a line the user didn't see.
private var ghost: String? {
guard let s = suggestion, !text.isEmpty else { return nil }
if s.lowercased().hasPrefix(text.lowercased()) {
return s.count > text.count ? String(s.dropFirst(text.count)) : nil
}
return "\(text.hasSuffix(" ") ? "" : " ")→ \(s)"
}
var body: some View {
ZStack(alignment: .topLeading) {
if let ghost {
Text(ghostText(ghost))
.frame(maxWidth: .infinity, alignment: .leading)
.allowsHitTesting(false)
.accessibilityHidden(true)
.background {
// The typed text alone, unwrapped, to measure where it
// ends; never drawn.
Text(text)
.fixedSize()
.hidden()
.background(GeometryReader { g in
Color.clear.preference(key: TypedSizeKey.self, value: g.size)
})
}
.onPreferenceChange(TypedSizeKey.self) { size in
typedWidth = size.width
lineHeight = size.height
}
}
// Vertical axis: a long line wraps like the logged lines do. Its
// Return key would insert a newline, so a newline means "submit".
// A pasted list ("курица 100⏎рис 50") keeps one food per line,
// as "; " — the separator that always splits (see splitLine).
TextField("Введите продукт…", text: $text, axis: .vertical)
.focused(focus)
.accessibilityIdentifier("line")
.submitLabel(.go)
.autocorrectionDisabled()
.foregroundStyle(Palette.text)
.onChange(of: text) { old, new in
let breaks = new.filter(\.isNewline).count
guard breaks > 0 else { return }
let line = new.filter { !$0.isNewline }
// Return: one newline, at the end or just put in where the
// caret was. Anything else came pasted.
let isReturn = breaks == 1 && (new.last?.isNewline == true || line == old)
if !isReturn {
text = new.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.joined(separator: "; ")
return
}
text = line
if line.trimmingCharacters(in: .whitespaces).isEmpty {
focus.wrappedValue = false
} else {
onSubmit(line)
}
}
.accessibilityHint(suggestion.map { L("Подсказка: \($0)") } ?? "")
// Over the field, a tap target where the grey text is: to the
// right of the typed text on its line, and anything below it.
// The typed text itself stays the field's (caret, selection).
if ghost != nil, let onGhostTap {
GeometryReader { g in
// Typed text that wraps has no measurable end: no target.
if typedWidth < g.size.width {
Color.clear
.contentShape(Rectangle())
.frame(width: g.size.width - typedWidth, height: lineHeight)
.offset(x: typedWidth)
.onTapGesture(perform: onGhostTap)
.accessibilityElement()
.accessibilityLabel(L("Другие варианты"))
.accessibilityIdentifier("ghost")
if g.size.height > lineHeight {
Color.clear
.contentShape(Rectangle())
.frame(width: g.size.width, height: g.size.height - lineHeight)
.offset(y: lineHeight)
.onTapGesture(perform: onGhostTap)
}
}
}
}
}
.font(.title3)
}
private struct TypedSizeKey: PreferenceKey {
static let defaultValue = CGSize.zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { value = nextValue() }
}
private func ghostText(_ ghost: String) -> AttributedString {
var typed = AttributedString(text)
typed.foregroundColor = .clear
var rest = AttributedString(ghost)
rest.foregroundColor = Palette.textTertiary
return typed + rest
}
}Список совпадений
Скрытая функция: раскрывается тапом по серому тексту подсказки или коротким свайпом вниз по строке. Сначала то, что уже записывали, затем точные совпадения, затем с опечаткой; одна строка на название. Тап по строке ставит название в поле — дальше как обычно.
Код · UI/DiaryView.swift
private var matchList: some View {
MatchList(items: matches) { f in
closeList()
pick(f)
}
}Стадион и сводка дня
Четыре вложенных кольца в форме капсулы, снаружи внутрь: калории, белки, жиры, углеводы. Заполняются по часовой от верхней середины. Переключите пилюлю — кольца доедут от прошлых значений; М показывает перебор: кольцо идёт на второй круг с тенью на кончике, чтобы было видно, насколько сверх цели.
Н и М — среднее по дням с записями за календарную неделю и месяц, и только до сегодняшнего дня: пропущенный день не тянет среднее к нулю, а план на завтра ещё не считается. Панель прячется, пока открыта клавиатура.
Код · UI/StadiumRings.swift
import SwiftUI
struct StadiumRing {
/// eaten / target, not clamped: past 1 the ring goes round a second lap
/// over itself, Apple-Fitness style, so an overshoot reads as "how much over".
var progress: Double
var color: Color
}
/// Nested progress tracks in a capsule shape, outer to inner — the diary's
/// pinned summary. Each ring fills clockwise from the top center; `content`
/// sits in the empty middle. Progress changes animate from the previous
/// values, so flipping Д/Н/М or adding an entry reads as the rings moving.
struct StadiumRings<Content: View>: View {
var rings: [StadiumRing]
var strokeWidth: CGFloat = 13
var gap: CGFloat = 3
@ViewBuilder var content: Content
@Environment(\.colorScheme) private var scheme
var body: some View {
ZStack {
ZStack {
ForEach(rings.indices, id: \.self) { i in
let ring = rings[i]
let inset = strokeWidth / 2 + CGFloat(i) * (strokeWidth + gap)
let p = max(0, ring.progress)
let style = StrokeStyle(lineWidth: strokeWidth, lineCap: .round)
let shape = StadiumShape(inset: inset)
ZStack {
shape.stroke(Palette.track(ring.color, dark: scheme == .dark), style: style)
shape.trim(from: 0, to: min(p, 1)).stroke(ring.color, style: style)
// Second lap over the full ring. The lap's end would
// vanish against the same colour underneath, so a
// short blurred dark stretch of the path sits just
// ahead of its tip — only there, never along the
// ring. Both are always present and trimmed, so they
// sweep along the path as the value animates.
let over = min(max(p - 1, 0), 0.999)
shape.trim(from: over, to: min(over + 0.012, 1))
.stroke(Palette.ringShadow.opacity(p > 1 ? 1 : 0), style: style)
.blur(radius: strokeWidth * 0.3)
shape.trim(from: 0, to: over).stroke(ring.color, style: style)
}
.animation(Motion.stadium, value: p)
}
}
content
.padding(CGFloat(rings.count) * (strokeWidth + gap) + 4)
}
}
}
/// A capsule path that starts at the top center and runs clockwise — the
/// same origin and direction as a clock-face ring.
struct StadiumShape: Shape {
var inset: CGFloat
func path(in bounds: CGRect) -> Path {
let r = bounds.insetBy(dx: inset, dy: inset)
guard r.width > 0, r.height > 0 else { return Path() }
let radius = min(r.width, r.height) / 2
var p = Path()
p.move(to: CGPoint(x: r.midX, y: r.minY))
p.addLine(to: CGPoint(x: r.maxX - radius, y: r.minY))
p.addArc(center: CGPoint(x: r.maxX - radius, y: r.midY), radius: radius, startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: false)
p.addLine(to: CGPoint(x: r.minX + radius, y: r.maxY))
p.addArc(center: CGPoint(x: r.minX + radius, y: r.midY), radius: radius, startAngle: .degrees(90), endAngle: .degrees(270), clockwise: false)
p.addLine(to: CGPoint(x: r.midX, y: r.minY))
return p
}
}Код · UI/SummaryPanel.swift
struct SummaryPanel: View {
@Environment(DiaryStore.self) private var store
let dayKey: String
@Binding var period: SummaryPeriod
var body: some View {
let targets = store.profile.targets
let total = switch period {
case .day: store.summary(dayKey...dayKey, average: false)
case .week: store.summary(DayKeys.weekRange(dayKey), average: true)
case .month: store.summary(DayKeys.monthRange(dayKey), average: true)
}
VStack(spacing: 14) {
PeriodToggle(period: $period)
StadiumRings(rings: [
StadiumRing(progress: ratio(total.kcal, targets.kcal), color: Palette.kcal),
StadiumRing(progress: ratio(total.protein, targets.protein), color: Palette.protein),
StadiumRing(progress: ratio(total.fat, targets.fat), color: Palette.fat),
StadiumRing(progress: ratio(total.carbs, targets.carbs), color: Palette.carbs),
]) {
VStack(spacing: 2) {
Text("\(Text("Ккал").foregroundStyle(Palette.kcalInk)) \(formatKcal(total.kcal))")
Text("\(Text("Б").foregroundStyle(Palette.proteinInk)) \(formatGrams(total.protein)) \(Text("Ж").foregroundStyle(Palette.fatInk)) \(formatGrams(total.fat)) \(Text("У").foregroundStyle(Palette.carbsInk)) \(formatGrams(total.carbs))")
}
.font(.title2.weight(.medium))
.monospacedDigit()
.foregroundStyle(Palette.text)
.contentTransition(.numericText())
.animation(Motion.number, value: total)
.lineLimit(1)
.minimumScaleFactor(0.5)
}
.frame(height: 212)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(L("Калории")) \(formatKcal(total.kcal)), \(L("Белки")) \(formatGrams(total.protein)), \(L("Жиры")) \(formatGrams(total.fat)), \(L("Углеводы")) \(formatGrams(total.carbs))")
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}Пилюля
Сегментный переключатель в духе iOS: светлый бегунок под выбранной буквой, остальные — вторым цветом. Бегунок едет (matchedGeometry), подпись меняет цвет за 0.22 с. Та же пилюля — К/Б/Ж/У не нужна: там одна буква, которая переключается тапом.
Код · UI/SummaryPanel.swift
/// iOS-style segmented pill: a thumb that slides under the chosen letter.
struct PeriodToggle: View {
@Binding var period: SummaryPeriod
@Namespace private var thumb
var body: some View {
HStack(spacing: 0) {
ForEach(SummaryPeriod.allCases) { p in
Text(p.letter)
.font(.body.weight(.semibold))
.foregroundStyle(p == period ? Palette.bg : Palette.textSecondary)
.padding(.horizontal, 8)
.frame(minWidth: 46, minHeight: 32)
.background {
if p == period {
Capsule().fill(Palette.text).matchedGeometryEffect(id: "thumb", in: thumb)
}
}
.contentShape(Rectangle())
.onTapGesture { withAnimation(Motion.pill) { period = p } }
.accessibilityLabel(p.fullName)
.accessibilityAddTraits(p == period ? [.isButton, .isSelected] : .isButton)
}
}
.padding(2)
.background(Capsule().fill(Palette.surfaceMuted))
.sensoryFeedback(.selection, trigger: period)
}
}Круглые кольца и плитки
Статистика в духе Fitness: кольца в круге на весь экран — средний день за период; под ними четыре плитки со значением, единицей и целью; ниже — дни недели, календарь месяца или месяцы мини-кольцами. Круглое кольцо, в отличие от стадиона, не идёт на второй круг: перебор здесь говорят цифры.
Код · UI/ActivityRings.swift
import SwiftUI
/// Nested circular progress rings, outer to inner, Apple-Fitness style —
/// the statistics screen's picture of a period. Each fills clockwise from
/// the top; a ring past its target stays full (the diary's stadium rings
/// show the overshoot, here the numbers do).
struct ActivityRings<Content: View>: View {
var rings: [StadiumRing]
var strokeWidth: CGFloat = 8
var gap: CGFloat = 3
@ViewBuilder var content: Content
@Environment(\.colorScheme) private var scheme
init(rings: [StadiumRing], strokeWidth: CGFloat = 8, gap: CGFloat = 3, @ViewBuilder content: () -> Content = { EmptyView() }) {
self.rings = rings
self.strokeWidth = strokeWidth
self.gap = gap
self.content = content()
}
var body: some View {
ZStack {
ForEach(rings.indices, id: \.self) { i in
let ring = rings[i]
let inset = strokeWidth / 2 + CGFloat(i) * (strokeWidth + gap)
let p = min(max(ring.progress, 0), 1)
Circle()
.inset(by: inset)
.stroke(Palette.track(ring.color, dark: scheme == .dark), lineWidth: strokeWidth)
Circle()
.inset(by: inset)
.trim(from: 0, to: p)
.stroke(ring.color, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(Motion.rings, value: p)
}
content
}
.aspectRatio(1, contentMode: .fit)
}
}
/// The four rings for a total against the day's targets.
func ringsFor(_ n: Nutrition, targets: Nutrition) -> [StadiumRing] {
func ratio(_ v: Double, _ t: Double) -> Double { t > 0 ? v / t : 0 }
return [
StadiumRing(progress: ratio(n.kcal, targets.kcal), color: Palette.kcal),
StadiumRing(progress: ratio(n.protein, targets.protein), color: Palette.protein),
StadiumRing(progress: ratio(n.fat, targets.fat), color: Palette.fat),
StadiumRing(progress: ratio(n.carbs, targets.carbs), color: Palette.carbs),
]
}Код · UI/StatsView.swift
private func tile(_ label: String, _ value: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.subheadline).foregroundStyle(Palette.textSecondary)
Text(value).font(.title.weight(.medium)).monospacedDigit().foregroundStyle(color)
.contentTransition(.numericText())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.card, style: .continuous))
.accessibilityElement(children: .combine)
}Плашка «Удалено»
Внизу экрана на четыре секунды после удаления. Удаления подряд копятся под одной плашкой, «Вернуть» возвращает всё: ничего, удалённое при живой плашке, не теряется. Одна и та же плашка в дневнике и в «Моих продуктах».
Код · UI/UndoBar.swift
import SwiftUI
/// What was just deleted, kept for a few seconds so "Вернуть" can put it
/// back. Several deletes in a row stack up; one tap restores them all —
/// nothing deleted while the bar is up is ever lost.
@MainActor
@Observable
final class PendingUndo<Item> {
private(set) var items: [Item] = []
private var expiry: Task<Void, Never>?
static var window: Duration { .seconds(4) }
func add(_ item: Item) {
withAnimation { items.append(item) }
expiry?.cancel()
expiry = Task { [weak self] in
try? await Task.sleep(for: Self.window)
guard !Task.isCancelled else { return }
withAnimation { self?.items = [] }
}
}
/// Everything pending, most recent last; the bar goes down.
func take() -> [Item] {
expiry?.cancel()
let all = items
withAnimation { items = [] }
return all
}
}
/// "Удалено — Вернуть" at the bottom of a screen while something can still
/// be brought back.
struct UndoBar: View {
var shown: Bool
var restore: () -> Void
var body: some View {
if shown {
HStack {
Text("Удалено").foregroundStyle(Palette.text)
Spacer()
Button("Вернуть", action: restore)
.fontWeight(.semibold)
.foregroundStyle(Palette.accent)
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(.regularMaterial, in: Capsule())
.padding(.horizontal, 16)
.padding(.bottom, 8)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}Строки настроек и списков
Профиль — системная форма: группы, строка с числом справа и единицей, строка с выбором, строка-действие, подпись под группой. Число сохраняется, когда уходишь из поля или с экрана. Цели окрашены в цвет своей величины.
«Мои продукты» — системный список: название, пометка «блюдо» третьим цветом, калории на 100 г справа. Свайп влево — удалить, долгое нажатие — меню.
Код · UI/ProfileView.swift
/// A form row with a number at the right, saved when editing ends — or
/// when the screen goes away mid-edit. Text that isn't a number leaves the
/// stored value alone, and the row shows it again.
private struct NumberRow: View {
let label: String
let stored: String
let unit: String
let decimal: Bool
let color: Color
let save: (String) -> Void
@State private var text = ""
@FocusState private var focused: Bool
var body: some View {
HStack {
Text(label).foregroundStyle(color)
Spacer()
TextField("—", text: $text)
.accessibilityIdentifier("field.\(label)")
.keyboardType(decimal ? .decimalPad : .numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 90)
.focused($focused)
.onAppear { text = stored }
.onChange(of: stored) { if !focused { text = stored } }
.onChange(of: focused) { if !focused { commit() } }
.onDisappear { if focused { commit() } }
Text(unit).foregroundStyle(Palette.textSecondary)
}
}
private func commit() {
save(text)
// Whatever was stored after the save (the new number, or the old
// one when the text wasn't a number) is what the row shows.
DispatchQueue.main.async { text = stored }
}
}
/// The system share sheet.
private struct ActivityView: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}Шторка «Свой продукт»
Системная шторка с панелью «Отмена / Сохранить». Название, четыре числа на 100 г крупно с цветной буквой под каждым, вес порции. Одна шторка на четыре случая: новый продукт, правка своего, после фото этикетки (числа подставлены), после неизвестного штрихкода (с подсказкой сверху). Фокус сразу в первом пустом поле.
Код · UI/NewFoodSheet.swift
private func numberField(_ value: Binding<String>, _ label: String, _ color: Color, _ field: Field) -> some View {
VStack(alignment: .leading, spacing: 2) {
TextField("0", text: value)
.font(.largeTitle)
.monospacedDigit()
.keyboardType(.decimalPad)
.focused($focus, equals: field)
Text(label)
.font(.subheadline)
.foregroundStyle(color)
}
.frame(maxWidth: .infinity, alignment: .leading)
}Иконки
SF Symbols — родные знаки iOS, в залитом варианте там, где он есть. Свои иконки появятся вместе с дизайном; пока список того, что стоит, и где.
| SF Symbol | Где |
|---|---|
| line.3.horizontal | Меню, слева в шапке дневника |
| camera.fill · mic.fill | Строка ввода; микрофон во время диктовки — акцентным цветом, с пульсацией |
| xmark.circle.fill | Очистить строку |
| barcode.viewfinder · camera · photo | Меню камеры: штрихкод, этикетка, фото |
| pencil · trash | Меню записи и продукта: изменить, удалить |
| person.fill · chart.bar.fill · bookmark.fill | Меню: профиль, статистика, мои продукты |
| plus · carrot · frying.pan | «Мои продукты»: добавить — продукт, блюдо |
| square.and.arrow.up · square.and.arrow.down | Профиль: сохранить копию, восстановить |
Как править
Источник правды — код приложения в apps/ios/Krug. Эта страница собирается из него скриптом, поэтому менять нужно код, а не страницу:
- Цвета —
UI/Palette.swift: пара hex для светлой и тёмной темы на каждый токен. - Отступы, радиус, движение —
UI/Style.swift: шкалаSpace.scale, радиусRadius.card, анимацииMotion.…. Шкала и карточки движения выше берутся оттуда. - Шрифт — только системные стили (
.title3,.body…); размеры в пунктах не задаются. - Компоненты — файлы в
UI/; код под компонентами на этой странице берётся из них при сборке. - Текст страницы —
design-system/src/index.template.html; стили —style.css.
Железное правило
Экран не может взять цвет, отступ, радиус или анимацию «из головы». design-system/tools/lint.py читает каждый Swift-файл приложения и не пропускает: цвет, созданный на месте, или системный (.gray, .white…) вместо токена Palette; размер шрифта в пунктах; отступ не из шкалы; радиус числом; анимацию не из Motion; хаптику не из таблицы выше. Он запускается хуком перед каждым коммитом и в CI — коммит с нарушением не проходит.
Пересобрать: python3 design-system/tools/build.py. Хук перед коммитом не пропустит устаревшую страницу. Опубликованная версия обновляется при каждом пуше в main.