Y-Matter Type
对于 iOS 和 tvOS 的设计系统排版的一种固执己见的看法。

此框架使用 Figma 的排版概念来创建基于文本的 UI 元素(标签、按钮、文本字段和文本视图),这些元素按照 Figma 设计文件中的描述呈现自己(尤其是在行高方面调整自身大小),同时还支持动态类型缩放和粗体文本辅助功能设置。

许可

Y—MatterType 遵循 Apache 2.0 许可证

测试目标中包含的 Noto Sans 字体遵循 Open Font License 许可证。

文档

文档自动从源代码注释生成,并渲染为静态网站,托管在 GitHub Pages 上:https://yml-org.github.io/YMatterType/

什么是 Y—MatterType?

Y—MatterType 是一个框架,可帮助您在从基于 Figma 的设计构建 iOS 和 tvOS 用户界面时正确完成排版。

Y—MatterType 旨在实现以下目标

排版

排版表示字体,就像它们通常在设计文件中呈现的那样(无论是完整的视觉设计系统还是只是风格指南)。目的是在我们的代码中以与设计人员定义的方式完全相同的方式定义排版,以便我们应用程序中的标签或按钮具有与设计文件中相应组件完全相同的尺寸和外观(在默认缩放比例下)。其中至关重要的一部分(在实现用户界面时经常被忽略)是行高。使用 16 pt semibold San Francisco 字体很容易,但要使其还具有完全 24 pt 的行高,则需要额外的工程设计。 Y—MatterType 为您处理此问题。

Shows typography being scaled to largest and back to smallest Dynamic Type size. It also shows the affects of turning the Accessibility Bold Text feature on and off.

不仅仅是字体

字体是排版的主要部分,但不是唯一的部分。还有许多其他属性决定了文本的呈现方式:行高、字母间距、文本装饰、文本大小写等。为此,我们采用在标签、按钮、文本字段和文本视图上设置排版的方式,而不是设置字体。排版会根据当前的特征环境生成要使用的正确字体,并同时考虑动态类型和辅助功能粗体文本设置。(例如,如果用户启用了粗体文本,则该 16/24 pt semibold San Francisco 字体实际上可能是 16/24 pt *粗体* 字体。或者,根据动态类型设置,它可以放大到 32/48 pt 字体。)然后,字体和其他排版属性用于通过属性化字符串呈现文本。

定义排版

Typography 只是一个 struct,其中包含字体系列、字重、字体大小、行高等属性。它具有许多带有合理默认值的属性,但您至少需要指定字体系列、字重、字体大小和行高。

// Avenir Next, Bold 16/24 pts
let typography = Typography(
    familyName: "AvenirNext",
    fontWeight: .bold,
    fontSize: 16,
    lineHeight: 24
)

您的确切排版定义当然将取决于您的设计系统,但我们建议使用语义命名并在 Typography 结构体的扩展上声明静态属性。

extension Typography {
    /// Noto Sans, Semibold 32/36 pts
    static let largeTitle = Typography(
        familyName: "NotoSans",
        fontWeight: .semibold,
        fontSize: 32,
        lineHeight: 36,
        // closest matching `UIFont.TextStyle` used for scaling
        textStyle: .largeTitle
    )
    
    /// Noto Sans, Semibold 17/22 pts
    static let headline = Typography(
        familyName: "NotoSans",
        fontWeight: .semibold,
        fontSize: 17,
        lineHeight: 22,
        textStyle: .headline
    )
}

使用排版

有四个类(TypographyLabelTypographyButtonTypographyTextFieldTypographyTextView),它们分别继承其对应的 UIKit 类(UILabelUIButtonUITextFieldUITextView)。它们每个都有一个必需的初始化程序,该初始化程序采用 Typography。这使这些控件能够正确调整自身大小,以匹配它们所源自的设计。

// using `Typography.largeTitle` as defined in the previous snippet
let label = TypographyLabel(typography: .largeTitle)
let button = TypographyButton(typography: .largeTitle)

您也可以在这些类的任何实例上设置排版,类似于您可以设置字体的方式。

label.typography = .headline

但是,您应该*不要*直接设置 font 属性。只需设置排版,该类将处理字体。

在 SwiftUI 中使用排版

为了使排版元素在 SwiftUI 框架中工作,我们使用了 UIViewRepresentable,它是 UIKit 的包装器,可将视图集成到您的 SwiftUI 视图层次结构中。

*渲染单行标签:* TextStyleLabel 是我们创建的第一个包装组件,它渲染单行 TypographyLabel,因此具有属性 numberOfLines = 1。 将此属性设置为任何其他值都是未定义的行为。

// using `TextStyleLabel` in SwiftUI
TextStyleLabel("Another sample headline", typography: .systemLabel)

要添加特定的 TypographyLabel 配置,请使用 configuration 闭包

// using custom typography, the configuration closure and a View background modifier
TextStyleLabel(
    "This is a long text string that will not fit",
    typography: .ParagraphUber.small.fontSize(28).lineHeight(45),
    configuration: { label in
        label.lineBreakMode = .byTruncatingMiddle
    })
.background(Color.yellow.opacity(0.5))

注册自定义字体

任何自定义字体都需要作为资源包含在您的应用程序中,并在系统中注册。如果您正在构建一个简单的应用程序,则只需将它们添加到您的项目中,并在应用程序的 Info.plist 文件中像往常一样列出它们。但是,如果您想将它们构建到单独的 Swift 包中,那么也可以,并且 Y—MatterType 在 UIFont 上有一个扩展,可以更轻松地注册(和取消注册)您的字体文件。如果无法注册字体(并且如果已注册字体),它将引发错误,因此您将知道何时出现问题。请注意,如果使用 Swift 包文件中的 .copy 作为资源,则需要指定 subpath(如果使用 .process,则可能不需要指定)。

// Register font file "NotoSans-Regular.otf"
try UIFont.register(
    name: "NotoSans-Regular",
    fileExtension: "ttf",
    subpath: "Assets/Fonts",
    bundle: .module
)

由于 Bundle.module 仅在您的 Swift 包中可用,因此我们建议您从 Swift 包中公开一个辅助方法来注册字体,并在内部引用 Bundle.module

public struct NotoSansFontFamily: FontFamily {
    /// Font family root name
    let familyName: String = "NotoSans"
    
    // We've only bundled 3 weights for this font
    var supportedWeights: [Typography.FontWeight] = [.regular, .medium, .semibold]

    /// Register all 3 NotoSans fonts
    public static func registerFonts() throws {
        let names = makeFontNames()
        try names.forEach {
            try UIFont.register(name: $0, fileExtension: "ttf", bundle: .module)
        }
    }

    /// Unregister all 3 NotoSans fonts
    public static func unregisterFonts() throws {
        let names = makeFontNames()
        try names.forEach {
            try UIFont.unregister(name: $0, fileExtension: "ttf", bundle: .module)
        }
    }

    private static func makeFontNames() -> [String] {
        [
            "NotoSans-Regular",
            "NotoSans-Medium",
            // Semibold is used for medium fonts when Accessibility Bold Text is enabled
            "NotoSans-SemiBold"
        ]
    }
}

包括哪些字重

字体最多可以有 9 种不同的字重,范围从 ultralight (100) 到 black (900),但并非所有字体系列都支持每个字重。此外,您可能不希望包含设计系统中未使用的字重的字体,以尽可能减小捆绑包大小。但是,为了支持辅助功能粗体文本功能(允许用户请求更重的字体),对于设计系统中的每个字重,您还应包括下一个更重的字体。例如,如果您的设计系统仅使用常规 (400) 和粗体 (700) 字重的字体,如果可能,您应该包括(并注册)常规 (400)、medium (500)、粗体 (700) 和重 (800) 字重的字体文件。

启用辅助功能粗体文本后,FontFamily 将使用 supportedWeights 中列出的下一个更重的字重(如果有),否则将使用最重的支持字重。

使用系统字体

只想使用默认系统字体? Y—MatterType 满足您的需求。

extension Typography {
    /// System font, Semibold 17/22 pts
    static let headline = Typography(
        fontFamily: Typography.systemFamily,
        fontWeight: .semibold,
        fontSize: 17,
        lineHeight: 22,
        textStyle: .headline
    )
}

自定义字体系列

Y—MatterType 会尽最大努力自动将字体系列名称、字体样式(常规或斜体)和字重(从 ultralight 到 black)映射到字体的注册名称中,以便可以使用 UIFont(name:, size:) 加载它。(此注册的字体名称可能与字体文件的名称和字体系列的显示名称不同。)但是,某些字体系列可能需要自定义行为才能正确加载字体(例如,semibold 字重可能命名为“DemiBold”而不是更常见的“SemiBold”)。或者您的字体系列可能不包括所有 9 种可能的字重。为了支持这一点,您可以声明一个类或结构体,该类或结构体符合 FontFamily 协议,并使用它来初始化您的 Typography 实例。此协议有四个方法,每个方法都可以选择性地覆盖,以自定义如何加载给定字重的字体。可以覆盖 supportedWeights 属性。如果您的字体系列无法访问所有 9 种字重,则应覆盖 supportedWeights 并返回项目中捆绑的所有字体的字重。

该框架包含两个不同的 FontFamily 实现供您考虑(DefaultFontFamilySystemFontFamily)。

如果无法加载请求的字体(名称不正确或未注册),Y—MatterType 将退回到加载指定磅值和字重的系统字体。

struct AppleSDGothicNeoInfo: FontFamily {
    /// Font family root name
    let familyName: String = "AppleSDGothicNeo"
    
    // This font family doesn't support weights higher than Bold
    var supportedWeights: [Typography.FontWeight] = 
        [.ultralight, .thin, .light, .regular, .medium, .semibold, .bold]

    /// Generates a weight name suffix as part of a full font name. Not all fonts support all 9 weights.
    /// - Parameter weight: desired font weight
    /// - Returns: The weight name to use
    func weightName(for weight: Typography.FontWeight) -> String {
        switch weight {
        case .ultralight:
            // most font familes use "ExtraLight"
            return "UltraLight"
        case .thin:
            return "Thin"
        case .light:
            return "Light"
        case .regular:
            return "Regular"
        case .medium:
            return "Medium"
        case .semibold:
            return "SemiBold"
        case .bold, .heavy, .black:
            // this font family doesn't support weights higher than Bold
            return "Bold"
        }
    }
}

覆盖/扩展排版 UI 元素

Y—MatterType 的所有四个 UI 元素都可以子类化,并包括常用的覆盖点

(初始化视图时也会调用这三个方法。)

当用户在浅色和深色模式之间切换或启用或禁用增加对比度模式时,当您需要更新 cgColor 值时,adjustColors 可能会很有用。

class BorderButton: TypographyButton {
    ...
    
    override func adjustColors() {
        super.adjustColors()
        layer.borderColor = UIColor.primaryBorder.cgColor
    }
}

利用断点

您是否想在全屏平板电脑模式下与分屏平板电脑或手机中为控件使用不同的(更大的)排版?当水平或垂直尺寸类更改时,将调用可覆盖的 adjustBreakpoints 方法。这可能是更新排版的好时机。

extension Typography {
    struct Label {
        /// Label / Large (18/24 pt, medium)
        static let large = Typography(
            familyName: "NotoSans",
            fontWeight: .medium,
            fontSize: 18,
            lineHeight: 24,
            textStyle: .body
        )

        /// Label / Medium (16/20 pt, medium)
        static let medium = Typography(
            familyName: "NotoSans",
            fontWeight: .medium,
            fontSize: 16,
            lineHeight: 20,
            textStyle: .callout
        )
    }
}

extension Typography.Label {
    /// Determines the typography to use based upon the current trait environment.
    /// - Parameter traitCollection: the trait collection to consider
    /// - Returns: `Label.large` when the horizontal size class is `.regular`,
    ///  otherwise returns `Label.medium`
    static func current(traitCollection: UITraitCollection) -> Typography {
        switch traitCollection.horizontalSizeClass {
        case .regular:
            return large
        case .unspecified, .compact:
            fallthrough
        @unknown default:
            return medium
        }
    }
}

class BreakpointButton: TypographyButton {
    // adjust breakpoint if necessary
    override func adjustBreakpoint() {
        super.adjustBreakpoint()
        typography = .Label.current(traitCollection: traitCollection)
        // You might also wish to update the padding around the button's title.
    }
}

安装

您可以通过将 Y-MatterType 添加为包依赖项来将其添加到 Xcode 项目。

  1. 从“文件”菜单中,选择“添加包...
  2. 将“https://github.com/yml-org/YMatterType”输入到包存储库 URL 文本字段中
  3. 单击“添加包

为 Y-Matter Type 做贡献

要求

SwiftLint (linter)

brew install swiftlint

Jazzy (文档)

sudo gem install jazzy

设置

克隆存储库并在 Xcode 中打开 Package.swift

版本控制策略

我们使用 语义版本控制

{major}.{minor}.{patch}

例如

1.0.5

分支策略

我们为我们的框架使用简化的分支策略。

分支命名约定

feature/{ticket-number}-{short-description}
bugfix/{ticket-number}-{short-description}

例如

feature/CM-44-button
bugfix/CM-236-textview-color

拉取请求

在提交拉取请求之前,您应该

  1. 编译并确保没有警告和错误。
  2. 运行所有单元测试并确认一切通过。
  3. 检查单元测试覆盖率并确认所有新的/修改的代码都已完全覆盖。
  4. 从命令行运行 swiftlint 并确认没有违规。
  5. 从命令行运行 jazzy 并确认您有 100% 的文档覆盖率。
  6. 考虑使用 git rebase -i HEAD~{commit-count} 将最后 {commit-count} 个提交合并到功能块中。
  7. 如果父分支(通常是 main)的 HEAD 自您创建分支以来已更新,请使用 git rebase main 来变基您的分支。
    • *永远不要*将父分支合并到您的分支中。
    • *始终*基于父分支变基您的分支。

提交拉取请求时

合并拉取请求时

发布新版本

生成文档(通过 Jazzy)

您可以使用以下终端命令直接从源代码生成您自己的本地文档集

jazzy

这将在 /docs 目录下生成一组文档。 默认配置设置在默认配置文件 .jazzy.yaml 文件中。

要查看其他文档选项,请输入

jazzy --help

每次向 main 分支推送提交时,GitHub Action 会自动运行,并运行 Jazzy 来为我们的 GitHub 页面生成文档:https://yml-org.github.io/YMatterType/