TextStyle.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. extension Font {
  2. /// A dynamic text style based off [Apple's typography guidelines](https://developer.apple.com/design/human-interface-guidelines/typography).
  3. public enum TextStyle: Hashable, Sendable, CaseIterable, Codable {
  4. /// The font style for large titles.
  5. case largeTitle
  6. /// The font used for first level hierarchical headings.
  7. case title
  8. /// The font used for second level hierarchical headings.
  9. case title2
  10. /// The font used for third level hierarchical headings.
  11. case title3
  12. /// The font used for headings.
  13. case headline
  14. /// The font used for subheadings.
  15. case subheadline
  16. /// The font used for body text.
  17. case body
  18. /// The font used for callouts.
  19. case callout
  20. /// The font used for standard captions.
  21. case caption
  22. /// The font used for alternate captions.
  23. case caption2
  24. /// The font used in footnotes.
  25. case footnote
  26. }
  27. }
  28. extension Font.TextStyle {
  29. /// A text style's resolved properties.
  30. public struct Resolved: Sendable {
  31. /// The point size.
  32. public var pointSize: Double
  33. /// The weight.
  34. public var weight: Font.Weight = .regular
  35. /// The emphasized weight.
  36. public var emphasizedWeight: Font.Weight
  37. /// The line height, in points.
  38. public var lineHeight: Double
  39. /// Fallback to macOS's body text style.
  40. ///
  41. /// This isn't expected to ever get used because it's only reachable if
  42. /// we forgot to supply text styles for a new device class or missed a
  43. /// text style in a device class' text style lookup table.
  44. static let fallback = Self(
  45. pointSize: 13,
  46. weight: .regular,
  47. emphasizedWeight: .semibold,
  48. lineHeight: 16
  49. )
  50. /// Creates a resolved text style.
  51. public init(
  52. pointSize: Double,
  53. weight: Font.Weight = .regular,
  54. emphasizedWeight: Font.Weight,
  55. lineHeight: Double
  56. ) {
  57. self.pointSize = pointSize
  58. self.weight = weight
  59. self.emphasizedWeight = emphasizedWeight
  60. self.lineHeight = lineHeight
  61. }
  62. }
  63. /// Resolves the text style's concrete text properties for the given device
  64. /// class.
  65. ///
  66. /// Generally follows [Apple's typography guidelines][typography]. Our
  67. /// styles only differ from Apple's where Apple decided not to specify a
  68. /// text style for a specific platform.
  69. ///
  70. /// [typography]: https://developer.apple.com/design/human-interface-guidelines/typography
  71. public func resolve(for deviceClass: DeviceClass) -> Resolved {
  72. guard let textStyles = Self.resolvedTextStyles[deviceClass] else {
  73. logger.warning(Logger.Message(stringLiteral: "missing text styles for device class \(deviceClass)"))
  74. return .fallback
  75. }
  76. guard let textStyle = textStyles[self] else {
  77. logger.warning(Logger.Message(stringLiteral: "missing \(self) text style for device class \(deviceClass)"))
  78. return .fallback
  79. }
  80. return textStyle
  81. }
  82. private static let resolvedTextStyles: [DeviceClass: [Self: Resolved]] = [
  83. .desktop: desktopTextStyles,
  84. .phone: mobileTextStyles,
  85. .tablet: mobileTextStyles,
  86. .tv: tvTextStyles,
  87. .watch: watchTextStyles,
  88. ]
  89. private static let desktopTextStyles: [Self: Resolved] = [
  90. .largeTitle: Resolved(
  91. pointSize: 26,
  92. emphasizedWeight: .bold,
  93. lineHeight: 32
  94. ),
  95. .title: Resolved(
  96. pointSize: 22,
  97. emphasizedWeight: .bold,
  98. lineHeight: 26
  99. ),
  100. .title2: Resolved(
  101. pointSize: 17,
  102. emphasizedWeight: .bold,
  103. lineHeight: 22
  104. ),
  105. .title3: Resolved(
  106. pointSize: 15,
  107. emphasizedWeight: .semibold,
  108. lineHeight: 20
  109. ),
  110. .headline: Resolved(
  111. pointSize: 13,
  112. weight: .bold,
  113. emphasizedWeight: .heavy,
  114. lineHeight: 16
  115. ),
  116. .body: Resolved(
  117. pointSize: 13,
  118. emphasizedWeight: .semibold,
  119. lineHeight: 16
  120. ),
  121. .callout: Resolved(
  122. pointSize: 12,
  123. emphasizedWeight: .semibold,
  124. lineHeight: 15
  125. ),
  126. .subheadline: Resolved(
  127. pointSize: 11,
  128. emphasizedWeight: .semibold,
  129. lineHeight: 14
  130. ),
  131. .footnote: Resolved(
  132. pointSize: 10,
  133. emphasizedWeight: .semibold,
  134. lineHeight: 13
  135. ),
  136. .caption: Resolved(
  137. pointSize: 10,
  138. emphasizedWeight: .medium,
  139. lineHeight: 13
  140. ),
  141. .caption2: Resolved(
  142. pointSize: 10,
  143. weight: .medium,
  144. emphasizedWeight: .semibold,
  145. lineHeight: 13
  146. ),
  147. ]
  148. private static let mobileTextStyles: [Self: Resolved] = [
  149. .largeTitle: Resolved(
  150. pointSize: 34,
  151. emphasizedWeight: .semibold,
  152. lineHeight: 41
  153. ),
  154. .title: Resolved(
  155. pointSize: 28,
  156. emphasizedWeight: .semibold,
  157. lineHeight: 34
  158. ),
  159. .title2: Resolved(
  160. pointSize: 22,
  161. emphasizedWeight: .semibold,
  162. lineHeight: 28
  163. ),
  164. .title3: Resolved(
  165. pointSize: 20,
  166. emphasizedWeight: .semibold,
  167. lineHeight: 25
  168. ),
  169. .headline: Resolved(
  170. pointSize: 17,
  171. weight: .semibold,
  172. emphasizedWeight: .semibold,
  173. lineHeight: 22
  174. ),
  175. .body: Resolved(
  176. pointSize: 17,
  177. emphasizedWeight: .semibold,
  178. lineHeight: 22
  179. ),
  180. .callout: Resolved(
  181. pointSize: 16,
  182. emphasizedWeight: .semibold,
  183. lineHeight: 21
  184. ),
  185. .subheadline: Resolved(
  186. pointSize: 15,
  187. emphasizedWeight: .semibold,
  188. lineHeight: 20
  189. ),
  190. .footnote: Resolved(
  191. pointSize: 13,
  192. emphasizedWeight: .semibold,
  193. lineHeight: 18
  194. ),
  195. .caption: Resolved(
  196. pointSize: 12,
  197. emphasizedWeight: .semibold,
  198. lineHeight: 16
  199. ),
  200. .caption2: Resolved(
  201. pointSize: 11,
  202. emphasizedWeight: .semibold,
  203. lineHeight: 13
  204. ),
  205. ]
  206. // The tvOS large title and footnote styles are the only ones not from the
  207. // Apple typography guidelines. I've just made it up based off the ratios
  208. // used on other platforms to provide a consistent set of text styles
  209. // across all platforms.
  210. private static let tvTextStyles: [Self: Resolved] = [
  211. .largeTitle: Resolved(
  212. pointSize: 91,
  213. weight: .medium,
  214. emphasizedWeight: .bold,
  215. lineHeight: 80
  216. ),
  217. .title: Resolved(
  218. pointSize: 76,
  219. weight: .medium,
  220. emphasizedWeight: .bold,
  221. lineHeight: 96
  222. ),
  223. .title2: Resolved(
  224. pointSize: 57,
  225. weight: .medium,
  226. emphasizedWeight: .bold,
  227. lineHeight: 66
  228. ),
  229. .title3: Resolved(
  230. pointSize: 48,
  231. weight: .medium,
  232. emphasizedWeight: .bold,
  233. lineHeight: 56
  234. ),
  235. .headline: Resolved(
  236. pointSize: 38,
  237. weight: .medium,
  238. emphasizedWeight: .bold,
  239. lineHeight: 46
  240. ),
  241. .body: Resolved(
  242. pointSize: 29,
  243. weight: .medium,
  244. emphasizedWeight: .bold,
  245. lineHeight: 36
  246. ),
  247. .callout: Resolved(
  248. pointSize: 31,
  249. weight: .medium,
  250. emphasizedWeight: .bold,
  251. lineHeight: 38
  252. ),
  253. .subheadline: Resolved(
  254. pointSize: 38,
  255. weight: .regular,
  256. emphasizedWeight: .medium,
  257. lineHeight: 46
  258. ),
  259. .footnote: Resolved(
  260. pointSize: 27,
  261. weight: .medium,
  262. emphasizedWeight: .bold,
  263. lineHeight: 33
  264. ),
  265. .caption: Resolved(
  266. pointSize: 25,
  267. weight: .medium,
  268. emphasizedWeight: .bold,
  269. lineHeight: 32
  270. ),
  271. .caption2: Resolved(
  272. pointSize: 23,
  273. weight: .medium,
  274. emphasizedWeight: .bold,
  275. lineHeight: 30
  276. ),
  277. ]
  278. // Like largeTitle and footnote on TV, callout and subheadline here are inferred from other
  279. // device classes.
  280. private static let watchTextStyles: [Self: Resolved] = [
  281. .largeTitle: Resolved(
  282. pointSize: 36,
  283. emphasizedWeight: .bold,
  284. lineHeight: 38.5
  285. ),
  286. .title: Resolved(
  287. pointSize: 34,
  288. emphasizedWeight: .semibold,
  289. lineHeight: 36.5
  290. ),
  291. .title2: Resolved(
  292. pointSize: 28,
  293. emphasizedWeight: .semibold,
  294. lineHeight: 30.5
  295. ),
  296. .title3: Resolved(
  297. pointSize: 19,
  298. emphasizedWeight: .semibold,
  299. lineHeight: 21.5
  300. ),
  301. .headline: Resolved(
  302. pointSize: 21,
  303. weight: .semibold,
  304. emphasizedWeight: .semibold,
  305. lineHeight: 23.5
  306. ),
  307. .body: Resolved(
  308. pointSize: 21,
  309. emphasizedWeight: .semibold,
  310. lineHeight: 23.5
  311. ),
  312. .callout: Resolved(
  313. pointSize: 20,
  314. emphasizedWeight: .semibold,
  315. lineHeight: 22.5
  316. ),
  317. .subheadline: Resolved(
  318. pointSize: 19,
  319. emphasizedWeight: .semibold,
  320. lineHeight: 21.5
  321. ),
  322. .caption: Resolved(
  323. pointSize: 18,
  324. emphasizedWeight: .semibold,
  325. lineHeight: 20.5
  326. ),
  327. .caption2: Resolved(
  328. pointSize: 17,
  329. emphasizedWeight: .semibold,
  330. lineHeight: 19.5
  331. ),
  332. .footnote: Resolved(
  333. pointSize: 16,
  334. emphasizedWeight: .semibold,
  335. lineHeight: 18.5
  336. ),
  337. ]
  338. }