Files
proton-stream/ios/ProtonStream/UI/Theme/Typography.swift
T
nvrl 255e463fc8 feat(app,android,ios): display type, artwork fade-in, shimmer, lifting tiles and paged shelves
A first pass at making the three clients look like a streaming app rather
than a file browser, with no new data behind it.

- Type: a Hero rung (34) set in Inter Display Bold, the optical size Inter
  draws for large text, for the banner's name and a title page's name where
  no logo stands in. Desktop `Role::Hero` and `theme::display`; Android and
  iOS `displayLarge`, at a tighter 1.1 leading. The font has its private use
  mappings stripped like the other two cuts.
- Artwork fades in over its skeleton on Android and iOS, as it already did
  on desktop cards; art already decoded draws at once. Desktop episode
  stills and logos fade in too.
- Skeletons shimmer: a soft diagonal highlight instead of a pulse.
- Desktop tiles lift towards the viewer under the pointer, capped so a
  lifted tile never reaches its neighbour, and keyboard focus gets a ring
  of its own.
- Desktop shelves share one `ui::shelf`: edges fade into the page and, under
  the pointer, round buttons page the row along.
- Genres are chips on Android and iOS; quality (4K, HDR, 10-bit, Dual audio)
  moves out of the facts line into small outlined marks on all three.
- Desktop title page and hero buttons use a tonal style beside the accent
  one, and the player's top bar a dark glass style, instead of egui's stock
  buttons. Episode numbers leave monospace for the type ramp.
- The phone players' top and bottom bars are gradients rather than flat
  bands, and a double-tap seek is felt as well as seen.
2026-10-30 20:36:09 +01:00

103 lines
3.6 KiB
Swift

import SwiftUI
/// The type ramp, which is the desktop client's type ramp, under Material's
/// names so a view ported from the Android client asks for the same style.
///
/// Inter, Regular and SemiBold, as on the desktop and on Android: a `Medium`
/// role resolves to Regular. The rungs, largest first:
/// 34 / 26 / 20 / 18 / 17 / 15 / 14 / 13 / 12 / 11. The top one is the hero
/// line — the banner's name and the title page's — in Inter Display Bold and
/// set tighter. `TypeRampTests` pins them.
enum TypeRole: CaseIterable {
case displayLarge, displayMedium, displaySmall
case headlineLarge, headlineMedium, headlineSmall
case titleLarge, titleMedium, titleSmall
case bodyLarge, bodyMedium, bodySmall
case labelLarge, labelMedium, labelSmall
var size: CGFloat {
switch self {
case .displayLarge: Ramp.hero
case .displayMedium, .headlineLarge, .headlineMedium: Ramp.display
case .displaySmall, .titleLarge: Ramp.title
case .headlineSmall: Ramp.heading
case .titleMedium: Ramp.section
case .titleSmall: Ramp.subhead
case .bodyLarge: Ramp.body
case .bodyMedium, .labelLarge: Ramp.label
case .bodySmall, .labelMedium: Ramp.caption
case .labelSmall: Ramp.micro
}
}
var semibold: Bool {
switch self {
case .displayMedium, .displaySmall, .headlineLarge, .headlineMedium, .headlineSmall, .titleLarge:
true
default:
false
}
}
/// The Dynamic Type style each rung scales with.
var relative: Font.TextStyle {
switch size {
case Ramp.hero: .largeTitle
case Ramp.display: .title
case Ramp.title: .title2
case Ramp.heading: .title3
case Ramp.section: .headline
case Ramp.subhead: .subheadline
case Ramp.body: .body
case Ramp.label: .callout
case Ramp.caption: .footnote
default: .caption
}
}
/// Line height is derived rather than named per style: 1.35 of the size,
/// as on Android. Inter's own leading is about 1.21, so the rest is spacing.
var lineSpacing: CGFloat {
size * ((self == .displayLarge ? Ramp.heroLineHeight : Ramp.lineHeight) - 1.21)
}
/// The PostScript name of the face this style is set in.
func face(semibold: Bool? = nil) -> String {
if self == .displayLarge, semibold == nil { return "InterDisplay-Bold" }
return (semibold ?? self.semibold) ? "Inter-SemiBold" : "Inter-Regular"
}
}
enum Ramp {
static let hero: CGFloat = 34
static let display: CGFloat = 26
static let title: CGFloat = 20
static let heading: CGFloat = 18
static let section: CGFloat = 17
static let subhead: CGFloat = 15
static let body: CGFloat = 14
static let label: CGFloat = 13
static let caption: CGFloat = 12
static let micro: CGFloat = 11
static let lineHeight: CGFloat = 1.35
static let heroLineHeight: CGFloat = 1.1
}
extension Font {
static func inter(_ style: TypeRole, semibold: Bool? = nil) -> Font {
.custom(style.face(semibold: semibold), size: style.size, relativeTo: style.relative)
}
static func inter(size: CGFloat, semibold: Bool = false) -> Font {
.custom(semibold ? "Inter-SemiBold" : "Inter-Regular", size: size)
}
}
extension View {
/// One rung of the ramp. `semibold` overrides the style's own weight, the
/// counterpart of Compose's `fontWeight = FontWeight.SemiBold`.
func textStyle(_ style: TypeRole, semibold: Bool? = nil) -> some View {
font(.inter(style, semibold: semibold)).lineSpacing(style.lineSpacing)
}
}