11 功能组件:如何使用路由,支持多页面导航?
随着 App 功能的不断丰富,以内容和体验为导向的导航模式变得越来越流行。这种导航模式的特点是一个页面可以导航到任意一个其他的页面。
比如在 iOS 里使用 UIKit 来实现导航功能时,源 ViewController 需要知道目标 ViewController 的类型信息,换句话说就是源 ViewController 必须直接依赖目标 ViewController。这会导致什么问题呢?如果 App的多个模块之间需要相互导航,那么它们之间就会产生循环依赖,如下图所示。
假如随着 Moments App 不断发展,除了朋友圈功能以外,我们还可能新增商城功能和实时通讯功能。当用户点击朋友圈信息的时候可以打开商品信息页面,当点击朋友头像时可以进入实时通讯页面。而在商品信息页面里面,用户还可以打开朋友圈页面进行分享。
这种模块之间的循环依赖会引起一系列的问题,比如因为代码强耦合,导致代码变得难以维护。如果不同功能由不同产品研发团队负责开发与维护,循环依赖还会增加很多的沟通成本,每次一点小改动都需要通知其他团队进行更新。
那么,有没有什么好的办法解决这种问题呢?
路由方案的架构与实现
我们可以使用一套基于 URL 的路由方案来解决多个模块之间的导航问题。下面是这套路由方案的架构图。
这个架构分成三层,因为上层组件依赖于下层组件,我们从下往上来看。
-
最底层是基础组件层,路由模块也属于基础组件,路由模块不依赖于任何其他组件。
-
中间层是功能业务层,各个功能都单独封装为一个模块,他们都依赖于基础组件层,但功能层内的各个模块彼此不相互依赖,这能有效保证多个功能研发团队并行开发。
-
最上层是 App 容器模块,它负责把所有功能模块整合起来,形成一个完整的产品。
这套路由方案主要由两大部分组成,独立的路由模块和嵌入功能模块里面的导航组件。 接下来,我们以 Moments App 为例子一起看看这套方案是怎样实现的吧。
路由模块
路由模块非常简单,主要有两个协议(Protocol)和一个类组成,如下图所示。
AppRouting 和 AppRouter
我们先来看路由模块里的AppRouting
和AppRouter
。其中,AppRouting
协议定义了路由模块的接口而AppRouter
是AppRouting
协议的实现类。
AppRouting
协议的代码如下。
protocol AppRouting {
func register(path: String, navigator: Navigating)
func route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType)
}
这个协议只有两个方法:
-
用于注册 Navigator(导航器)的
register(path: String, navigator: Navigating)
方法; -
触发路由的
route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType)
方法。
其中route(to:from:using)
方法接收三个参数。
第一个是 URL,我们整套路由系统都是基于 URL 的,因此需要把 URL 传递进来进行导航。
第二个是类型为RoutingSource
的参数,该RoutingSource
是一个协议,代码如下:
protocol RoutingSource: class { }
extension UIViewController: RoutingSource { }
首先,我们定义一个名为RoutingSource
的空协议,然后让UIViewController
遵循该协议。这样就能让route(to:from:using)
方法与UIViewController
进行解耦。
第三个参数是TransitionType
类型。代码如下:
enum TransitionType: String {
case show, present
}
TransitionType
是一个枚举(enum)类型,用于表示导航过程中的转场动作。show
用于把新的目标 ViewController 推进(push)到当前的UINavigationController
里面。而present
会把新的目标 ViewController 通过模态窗口(modal)的方式来呈现。
至于AppRouter
是AppRouting
协议的实现类,其他的具体代码如下:
final class AppRouter: AppRouting {
static let shared: AppRouter = .init()
private var navigators: [String: Navigating] = [:]
private init() { }
func register(path: String, navigator: Navigating) {
navigators[path.lowercased()] = navigator
}
func route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType = .present) {
guard let url = url, let sourceViewController = routingSource as? UIViewController ?? UIApplication.shared.rootViewController else { return }
let path = url.lastPathComponent.lowercased()
guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
let parameters: [String: String] = (urlComponents.queryItems ?? []).reduce(into: [:]) { params, queryItem in
params[queryItem.name.lowercased()] = queryItem.value
}
navigators[path]?.navigate(from: sourceViewController, using: transitionType, parameters: parameters)
}
}
AppRouter
首先定义了一个用于存储各个 Navigator 的私有属性navigators
。navigators
是一个字典类型,它的 Key 是字符串类型,用于保存 URL 的路径值。而所存储的值是具体的 Navigator 的实例。
然后,AppRouter
实现了register
和route
两个方法。register
方法的实现非常简单,就是把path
和navigator
存到私有属性navigators
里面。接着我详细介绍一下route
方法的实现。
因为整套路由方案都是基于 URL 进行导航,因此在该方法里面,首先需要检测url
是否为空,如果为空就直接返回了,然后把routingSource
向下转型 (downcast) 为UIViewController
,如果为空就使用rootViewController
作为sourceViewController
来表示导航过程中的源 ViewController。
这些检验都通过以后,我们从url
来取出path
作为导航的 Key,同时从 Query String 里面取出parameters
并作为参数传递给目标 ViewController。
最后一步是根据path
从navigators
属性中取出对应的 Navigator,然后调用其navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String: String])
方法进行导航。
Navigating 协议
除了AppRouting
和AppRouter
以外,路由模块的核心还包含了一个叫作Navigating
的协议。它负责具体的导航工作,下面我们一起看看这个协议的定义与实现吧。
protocol Navigating {
func navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String: String])
}
extension Navigating {
func navigate(to destinationViewController: UIViewController, from sourceViewController: UIViewController, using transitionType: TransitionType) {
switch transitionType {
case .show:
sourceViewController.show(destinationViewController, sender: nil)
case .present:
sourceViewController.present(destinationViewController, animated: true)
}
}
}
Navigating
协议负责桥接路由模块和其他功能模块,它只定义了一个名叫navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String: String])
的方法供AppRouter
来调用。
同时我们也给Navigating
定义了一个叫作navigate(to destinationViewController: UIViewController, from sourceViewController: UIViewController, using transitionType: TransitionType)
的扩展方法 (Extension method) 来统一封装导航的处理逻辑。
当transitionType
为.show
的时候,该方法会调用UIViewController
的show(_ vc: UIViewController, sender: Any?)
方法进行导航。在调用show
方法的时候,iOS 系统会判断sourceViewController
是存放在 NavigationController 还是 SplitViewController 里面,并触发相应的换场(Transition)动作。例如当sourceViewController
存放在 NavigationController 里面的时候就会把destinationViewController
推进 NavigationController 的栈(Stack)里面。
当transitionType
为.present
的时候,我们就调用UIViewController
的present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil)
方法进行导航。在调用present
方法的时候,iOS 系统会把destinationViewController
通过模态窗口的方式呈现。
有了Navigating
协议以后,我们看看功能模块是怎样关联到路由模块的。
导航组件
所有功能模块都通过 Navigator 类型为路由模块提供导航功能。一个目标 ViewController 对应一个 Navigator。假如商城模块有商城主页和商品信息页面两个 ViewController,那么商城模块就需要提供两个 Navigtor 来分别导航到这两个 ViewController。
下面我们以 Moments App 中内部隐藏功能菜单模块为例子,看看 Navigator 是怎样实现的。
内部隐藏功能菜单模块有两个 ViewController,因此需要定义两个不同的 Navigator。它们都遵循了Navigating
协议。
InternalMenuNavigator
InternalMenuNavigator
负责导航到InternalMenuViewController
。下面是它的具体代码实现。
struct InternalMenuNavigator: Navigating {
func navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String : String]) {
let navigationController = UINavigationController(rootViewController: InternalMenuViewController())
navigate(to: navigationController, from: viewController, using: transitionType)
}
}
从代码可以看到,InternalMenuNavigator
的实现非常简单。首先,初始化InternalMenuViewController
的实例,然后把该实例放置到一个UINavigationController
里面。接下来我们调用Navigating
的扩展方法navigate(to destinationViewController: UIViewController, from sourceViewController: UIViewController, using transitionType: TransitionType)
来进行导航。
DesignKitDemoNavigator
DesignKitDemoNavigator
负责导航到DesignKitDemoViewController
。下面是实现的代码。
struct DesignKitDemoNavigator: Navigating {
func navigate(from viewController: UIViewController, using transitionType: TransitionType, parameters: [String: String]) {
guard let productName = parameters["productname"], let versionNumber = parameters["version"] else {
return
}
let destinationViewController = DesignKitDemoViewController(productName: productName, versionNumber: versionNumber)
navigate(to: destinationViewController, from: viewController, using: transitionType)
}
}
与InternalMenuNavigator
不一样的地方是,DesignKitDemoNavigator
从parameters
中取出了productName
和versionNumber
两个参数的值,然后传递给DesignKitDemoViewController
进行初始化。最后也是调用Navigating
的扩展方法navigate(to:from:using:)
进行导航。
路由方案的使用
以上是有关路由方案的架构和实现,有了这个路由方案以后,那我们该如何使用它呢?接下来我将从它的注册与调用、Universal Links 的路由和验证来介绍下。
路由的注册与调用
因为App 容器模块依赖所有的功能模块和路由模块,我们可以把路由注册的逻辑放在该模块的AppDelegate
里面,代码如下:
let router: AppRouting = AppRouter.shared
router.register(path: "InternalMenu", navigator: InternalMenuNavigator())
router.register(path: "DesignKit", navigator: DesignKitDemoNavigator())
从上面可以看到,我们通过传递path
和navigator
的实例来注册路由信息。注册完毕以后,各个功能模块就可以调用route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType)
方法进行路由。下面是如何路由到内部功能菜单页面的代码。
router.route(to: URL(string: "\(UniversalLinks.baseURL)InternalMenu"), from: rootViewController, using: .present)
路由的过程中需要传入一个 URL,源 ViewController 以及换场的类型三个参数。
下面是路由到 DesignKit 范例页面的具体代码。
router.route(to: URL(string: "\(UniversalLinks.baseURL)DesignKit?productName=DesignKit&version=1.0.1"), from: routingSourceProvider(), using: .show)
这个例子中,我们通过 Query String 的方式把productName
和version
参数传递给目标 ViewController。
Universal Links 的路由
我们之所以选择基于 URL 的路由方案,其中的一个原因是对 Universal Links 的支持。当我们的 App 支持 Universal Links 以后,一旦用户在 iOS 设备上打开 Universal Links 所支持的 URL 时,就会自动打开我们的 App。
根据 App 是否支持 Scenes 来区分,目前在 UIKit 里面支持 Universal Links 有两种方式。如果 App 还不支持 Scenes 的话,我们需要在AppDelegate
里面添加 Universal Links 的支持的代码,如下所示:
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL else {
return false
}
let router: AppRouting = AppRouter.shared
router.route(to: incomingURL, from: nil, using: .present)
return true
}
我们首先检查userActivity.activityType
是否为NSUserActivityTypeBrowsingWeb
,并把 URL 取出来。如果验证都通过,就可以调用AppRouting
的route(to url: URL?, from routingSource: RoutingSource?, using transitionType: TransitionType)
方法进行路由。
在调用route
方法的时候,我们把nil
传递给routingSource
并指定换场方式为.present
。这样路由模块就会通过模态窗口把目标 ViewController 呈现出来。
如果 App 已经使用 Scene,例如我们的 Moments App,那么我们需要修改SceneDelegate
的scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)
方法来支持 Universal Links,代码如下:
if let userActivity = connectionOptions.userActivities.first,
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL {
let router: AppRouting = AppRouter.shared
router.route(to: incomingURL, from: nil, using: .present)
}
从代码可见,当我们从connectionOptions
取出userActivity
以后,后面的处理逻辑和上面AppDelegate
的实现方式一模一样,在这里我就不赘述了。
路由的验证
当我们的 App 支持 Universal Links 以后,我们需要在 Navigator 里面增加一些验证的代码,否则可能会引起外部系统的攻击,例如 Moments App 的内部隐藏功能菜单不想给 App Store 用户使用,我们可以在InternalMenuNavigator
里面添加以下的验证代码。
let togglesDataStore: TogglesDataStoreType = BuildTargetTogglesDataStore.shared
guard togglesDataStore.isToggleOn(BuildTargetToggle.debug) || togglesDataStore.isToggleOn(BuildTargetToggle.internal) else {
return
}
这段代码会检查当前的 App 是否为开发环境或者测试环境的版本,如果“不是”,说明当前的 App 是 App Store 版本,我们就直接退出,不允许打开内部功能菜单。
总结
在这一讲中我介绍了一个基于 URL 的通用路由方案的实现方式,有了这个路由方案,不但可以帮助所有功能模块的解耦,而且能很方便地支持 Universal Links。
当我们的 App 支持 Universal Links 以后,需要特别注意对路由的 URL 进行验证,否则会很容易被外部系统进行攻击。这些验证的手段包括不应该允许 Universal Links 更新或者删除数据,不允许 Universal Links 访问任何敏感数据。
思考题:
在软件开发中,只有合适的方案,没有完美的方案。基于 URL 的路有方案也有一些需要处理的难题,例如如何传递数组和大对象,请问你是怎样处理这些问题的呢?
可以把回答写到下面的留言区哦,我们一起探讨一下。下一讲将介绍如何设置多语言支持。
12 功能组件:如何设置多语言支持,为全球化做准备?
作为 iOS 开发者,不知道你有没有遇到过这样的情况:每次增加一种新语言,都需要重新改一遍,特别是在 App 进入其他国家的市场时,需要修改整个 App 的代码才能加入新语言。这样是不是很麻烦?
其实这种情况完全可以通过多语言设置来解决。下面我就以 Moments App 为例,看看怎样支持多语言。
安装 SwiftGen
Moments App 使用了 SwiftGen 来自动生成支持多语言常量字符串。为了保证整个团队所使用 SwiftGen 的版本都保持一致,我们使用 CocoaPods 来安装 SwiftGen。具体到 Moments App 项目,我们在 Podfile 文件中添加 SwiftGen Pod 即可。
pod 'SwiftGen', '= 6.4.0', configurations: ['Debug']
为了在每次编译代码的时候,SwiftGen 都会启动代码生成任务,我们需要在主 App TargetMoments的 Build Phases 里面添加 Run SwiftGen 步骤,然后配置它执行"${PODS_ROOT}/SwiftGen/bin/swiftgen"
命令。
这里要注意,由于我们自己的源代码会使用到 SwiftGen 所生成的代码,因此必须把 Run SwiftGen 步骤放在 Compile Source 步骤之前。
增加多语言支持
Xcode 使用.strings
文件来支持多语言。那什么是.strings
呢?.strings
文件是一个资源文件,用于存储各种语言的文本。该文件里面保存了一堆 Key-Value 信息,例子如下:
"userNameKey" = "User name";
其中userNameKey
是 Key,而User name
是具体的值。在 Swift 代码中,我们可以把 Key 传递给NSLocalizedString
方法来取出.strings
文件里配置的值。具体代码如下:
let use rName = NSLocalizedString("userNameKey", comment: "Label text for user name")
由于 Moments App 使用了纯代码的方式来呈现 UI,我们需要在 Xcode 里面建立一个名叫Localizable.strings的文件来存储 Key-Value 信息。该文件保存在 Moments/Resources/en.lproj 文件夹下面,其中en
表示英文,因为 Moments App 的默认语言是英文,假如你的 App 的默认语言是简体中文,那么应该放在 zh-Hans.lproj 文件夹下面。
那怎样支持新语言呢?我们可以在 Project Info 配置里面的 Localizations 下面点击加号按钮 (+),然后选择需要添加的语言,如下图所示,我们添加了简体中文。
接着选择要增加简体中文支持的资源文件。在 Moments App 里面,我们使用了纯代码的方式来编写 UI,因此我们只选择刚才新建的Localizable.strings文件。
然后你会看到在Localizable.strings下多了一个Localizable.strings(Chinese, Simplified) 文件用于保存简体中文的文本信息。
现在我们可以在Localizable.strings里面添加下面的 Key-Value 来让 App 显示中文了。
"userNameKey" = "用户名";
当用户在 iOS 的 Settings App 里面把语言选择为简体中文以后, App 里面的文本就会变成中文。我们也可以使用同样的办法来增加不同的语言支持。
配置 swiftgen.yml 文件
不知道你发现没有,调用NSLocalizedString
方法来取出文本并不方便,一不小心就会把 Key 写错了。那么,有没有什么好的办法方便我们使用.strings
文件里面的文本呢?有,那就是使用 SwiftGen 来自动生成带类型信息的常量字符串。
为什么呢?因为 SwfitGen 在执行过程中会读取 swiftgen.yml 文件里面的信息,要知道, swiftgen.yml 文件就是用来告诉 SwiftGen 读取那些文件,使用哪个模版以及在哪里存放生成的文件。那么,如何配置该文件,让 SeiftGen 帮我们生成用于全球化和本地化的常量字符串呢?
做法非常简单,我们可以在 swiftgen.yml 文件添加以下一段代码。
strings:
inputs:
- Moments/Resources/en.lproj
outputs:
- templateName: structured-swift5
output: Moments/Generated/Strings.swift
其中strings
表示这是一个用户生成常量字符串的任务。inputs
用于指定.strings
文件所在的位置,在我们的项目中,该文件位于 Moments/Resources/en.lproj。要注意的是,我们只需要指定一个语言的文件夹就行,它通常是默认开发语言的文件夹。
outputs.templateName
表示生成文件所使用的模版,我们使用structured-swift5
模版表示所生成的代码支持点号 (.) 分割 Swift 5 代码。outputs.output
表示所生成文件存放的位置。以下是生成的 Moments/Generated/Strings.swift :
internal enum L10n {
internal enum InternalMenu {
/// Area 51
internal static let area51 = L10n.tr("Localizable", "internalMenu.area51")
/// Avatars
internal static let generalInfo = L10n.tr("Localizable", "internalMenu.generalInfo")
}
}
因为我们在 Localizable.strings 文件里定义 Key 的时候使用了点号,SwiftGen 会使用内嵌套枚举类型 (Nested enum) 来把各个常量字符串通过命名空间进行分组。下面是英文版本 Localizable.strings 文件的部分定义。
// Internal Menu
"internalMenu.area51" = "Area 51";
"internalMenu.generalInfo" = "General Info";
// Moments List
"momentsList.errorMessage" = "Something went wrong, please try again later";
我们可以对比一下中文版本 Localizable.strings 文件的部分定义。
// Internal Menu
"internalMenu.area51" = "51 区";
"internalMenu.generalInfo" = "通用信息";
// Moments List
"momentsList.errorMessage" = "出错啦,请稍后再试";
可以看到,我们在定义所有 Key 的时候,都使用了点号进行分割,这可以帮助我们分组各类文本的同时,保证不同语言的文本信息都使用同样的 Key。
使用生成的字符串
当 SwiftGen 自动生成那些常量字符串以后,我们就可以很方便地使用它们,下面的代码演示了如何调用这些字符串。
let title = L10n.InternalMenu.area51
let infoSection = InternalMenuSection(
title: L10n.InternalMenu.generalInfo,
items: [InternalMenuDescriptionItemViewModel(title: appVersion)]
)
我们可以使用枚举类型L10n
来取出相应的常量字符串。L10n
的扩展方法 (Extension method)会根据当前用户的语言选择来读取相应的 Localizable.strings 文件,并返回对应语言的字符串来显示给用户。
下面是 Moments App 在英文语言和中文语言环境下的显示。
总结
这一讲,我介绍了如何使用.strings
文件和 SwiftGen 来快速设置多语言支持。有了.strings
文件,支持新的语言变得非常简单,甚至可以在没有程序员的情况下,由翻译人员来翻译并发布新的语言。另外,有了 SwiftGen 所生成的常量字符串,我们不会再把错误的 Key 传递给NSLocalizedString
,从而提高了代码的质量。可以说,这个设置是一本万利,哪怕目前你的 App 还没有支持多个语言,我还是建议你花一丁点时来设置多语言支持。
思考题
请问你们的 App 支持多种语言吗?通过怎样的方法来支持的?有没有什么经验可以分享给大家?
源码地址:
swiftgen.yml文件
https://github.com/lagoueduCol/iOS-linyongjian/blob/main/Moments/swiftgen.yml
13 功能组件:如何设置动态字体,提升视力辅助功能?
如今在 App 开发当中,支持动态字体已成为标配。 2019 年 Airbnb 统计,有 30% 的 iOS 用户没有使用默认的字体大小。这说明什么呢?说明越来越多的用户更喜欢依据自己的习惯来设置字体的大小来符合他们的阅读习惯。
那什么是动态字体(Dynamic Type)呢?动态字体实际上就是允许用户选择屏幕上显示文本内容的大小。它能帮助一些用户把字体变大来提高可读性,也能方便一些用户把字体变小,使得屏幕能显示更多内容。
以上就是动态字体的效果,一般在设置 App->辅助功能->显示与字体大小->更大字体里面通过拖动滑动条来改变系统字体的大小。
目前流行的 App 都已经支持动态字体,假如我们的 App 不支持,当用户在不同 App 之间切换的时候就会感觉到很唐突,甚至会因为阅读体验的问题而直接删除。
支持动态字体
那么怎样才能让 iOS App 支持动态字体呢?我们需要为显示文本的组件,例如UILabel
,UITextView
和UIButton
指定能自动调整大小的字体。比如下面是为UILabel
增加动态字体支持的代码。
label.font = UIFont.preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
首先,我们使用了UIFont.UITextStyle
的.body
来创建一个UIFont
的实例并赋值给 Label 的font
属性。 然后把该 Label 的adjustsFontForContentSizeCategory
设置为true
来让它响应用户的动态字体设置。这个属性默认值就为true
,假如我们不想让文本自动支持动态字体,可以把它设为false
。
目前,iOS 系统为我们提供了Large Title,Title 1和Body等 11 种字体风格,你可以在苹果官方的 《Human Interface Guidelines 》文档里查看它们的具体规范。如下图所示,我从中截取当用户选择默认大小的情况下各种字体风格所对应的字体粗细和大小等信息。其中Large Title的字号是 34pt,Title1 是 28pt,它们的字体粗细都是“Regular”。
Dynamic Type Sizes(来源:Human Interface Guidelines)
为第三方字体库加入动态字体支持
绝大多数情况下,我们应该使用 iOS 系统提供的内置字体库。但也有一些例外,例如使用自定义字体库来强调自身品牌,或者使用搞怪字体为游戏提供沉浸式体验。这个时候怎么办呢?我们可以使用第三方字体库,同时为它配置动态字体的支持。代码示例如下:
guard let customFont = UIFont(name: "CustomFont", size: UIFont.labelFontSize) else {
fatalError("Failed to load the "CustomFont" font. Make sure the font file is included in the project and the font name is spelled correctly."
)
}
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: customFont)
label.adjustsFontForContentSizeCategory = true
第一步是通过传递字体库的名字,来加载并初始化类型为UIFont
的对象customFont
。
第二步是传入字体风格.headline
,来初始化一个UIFontMetrics
的对象。
第三步是把customFont
传入scaledFont(for font: UIFont) -> UIFont
方法,并把返回值赋给 Label 的font
。这样label
就能即使用第三方的字体库又能支持动态字体。
Moments App 的字体定义
和大部分的 App 一样,我们没有在 Moments App 里使用第三方字体库。而是根据 07 讲的设计规范,在 DesignKit 组件里面实现了自定义的字体集合,具体代码如下:
public extension UIFont {
static let designKit = DesignKitTypography()
struct DesignKitTypography {
public var display1: UIFont {
scaled(baseFont: .systemFont(ofSize: 42, weight: .semibold), forTextStyle: .largeTitle, maximumFactor: 1.5)
}
public var display2: UIFont {
scaled(baseFont: .systemFont(ofSize: 36, weight: .semibold), forTextStyle: .largeTitle, maximumFactor: 1.5)
}
public var title1: UIFont {
scaled(baseFont: .systemFont(ofSize: 24, weight: .semibold), forTextStyle: .title1)
}
public var title2: UIFont {
scaled(baseFont: .systemFont(ofSize: 20, weight: .semibold), forTextStyle: .title2)
}
public var title3: UIFont {
scaled(baseFont: .systemFont(ofSize: 18, weight: .semibold), forTextStyle: .title3)
}
public var title4: UIFont {
scaled(baseFont: .systemFont(ofSize: 14, weight: .regular), forTextStyle: .headline)
}
public var title5: UIFont {
scaled(baseFont: .systemFont(ofSize: 12, weight: .regular), forTextStyle: .subheadline)
}
public var bodyBold: UIFont {
scaled(baseFont: .systemFont(ofSize: 16, weight: .semibold), forTextStyle: .body)
}
public var body: UIFont {
scaled(baseFont: .systemFont(ofSize: 16, weight: .light), forTextStyle: .body)
}
public var captionBold: UIFont {
scaled(baseFont: .systemFont(ofSize: 14, weight: .semibold), forTextStyle: .caption1)
}
public var caption: UIFont {
scaled(baseFont: .systemFont(ofSize: 14, weight: .light), forTextStyle: .caption1)
}
public var small: UIFont {
scaled(baseFont: .systemFont(ofSize: 12, weight: .light), forTextStyle: .footnote)
}
}
}
private extension UIFont.DesignKitTypography {
func scaled(baseFont: UIFont, forTextStyle textStyle: UIFont.TextStyle = .body, maximumFactor: CGFloat? = nil) -> UIFont {
let fontMetrics = UIFontMetrics(forTextStyle: textStyle)
if let maximumFactor = maximumFactor {
let maximumPointSize = baseFont.pointSize * maximumFactor
return fontMetrics.scaledFont(for: baseFont, maximumPointSize: maximumPointSize)
}
return fontMetrics.scaledFont(for: baseFont)
}
}
我们为UIFont
定义了一个类型扩展(Extension)。为了调用的时候具有命名空间,我们在这个扩展里面定义了一个名叫DesignKitTypography
的内嵌结构体(Nested struct),然后定义了一个静态属性来引用该结构体。
根据之前设计规范里面的字体定义,我们在DesignKitTypography
结构体里面分别定义了display1
、display2
、title1
等一系列的字体属性。比如display1
用于页面唯一的大标题,title1
用于第一级段落标题,body
用于正文等等,它们都调用了同一个私有方法scaled(baseFont: UIFont, forTextStyle textStyle: UIFont.TextStyle = .body, maximumFactor: CGFloat? = nil)
来生成一个支持动态字体的UIFont
。这里的scaled
方法是怎样实现的呢?
首先,该方法通过传递进来的textStyle
参数初始化一个UIFontMetrics
对象。这样能保证我们自定义的字体会以 iOS 自带的TextStyle
作为基准来进行缩放,然后判断maximumFactor
是否为空。
如果不为空就计算出maximumPointSize
并调用scaledFont(for font: UIFont, maximumPointSize: CGFloat)
方法来返回一个UIFont
的实例。例如,为了大号的字体display1
和display2
不会无限放大,我们在生成它们的时候把maximumFactor
设置为1.5
。如果maximumFactor
为空,我们就调用scaledFont(for font: UIFont)
方法并直接返回UIFont
的实例。
有了DesignKitTypography
结构体的定义,以后需要增加新的字体类型也非常简单,只需要定义新字体的名字、字体粗细和大小就可以了。例如在这里我新增caption2
的代码,它也使用了系统自带的字体库,并把字体大小设为10pt
,字体粗细设为细体,同时使用了.caption2
作为基准字体风格。 代码示例如下:
public var caption2: UIFont {
scaled(baseFont: .systemFont(ofSize: 10, weight: .light), forTextStyle: .caption2)
}
完成了这些字体集合的定义以后,我们可以在代码中很方便地使用它们。代码如下:
label1.font = UIFont.designKit.title1
button.titleLabel?.font = UIFont.designKit.bodyBold
我们可以通过UIFont.designKit
取出支持动态字体的UIFont
类型并赋值给对应的font
属性即可,例如UILabel
的font
属性以及UIButton
的titleLabel
。
测试动态字体
当我们的 App 支持了动态字体以后,在开发过程中需要及时测试,否则可能会不小心引入 UI 的 Bug。幸运的是 Xcode 为我们带来一个名叫Accessibility Inspector的工具来简化动态字体的测试流程。
怎么使用它呢?请看下面的动图:
它使用方法很简单,我们可以在Accessibility Inspector工具里选择运行 Moments App 的 Simulator,然后点击 Settings 按钮,接着拖动滑动条来改变 Font size 的大小,以此来测试 App 对动态字体的响应情况。
总结
这一讲我主要介绍了如何支持动态字体,同时以 Moments App 为例,介绍了如何实现自定义的字体集合。
最后,结合我经验,在加入了动态字体支持后,建议你需要注意以下几点。
-
要经常使用Accessibility Inspector工具来测试带文本内容的 UI,保证所有文本都能正常显示。
-
不要硬编码文本组件所在容器的高度和宽度,容器的高度和宽度应该随着文本的大小而伸缩,否则当用户选择大字体的时候,可能导致部分文本被遮挡。
-
除了特殊情况,不要硬编码
UILable
组件文本显示的行数,否则可能导致文本显示不全。 -
并不是所有文本都需要支持动态字体,例如 Tabbar 上的标题就需要指定静态的字体大小。
思考题:
请结合前几讲所学的内容,实现下面视频中的功能,该功能会列举 iOS 系统自动的所有TextStyle,并把它们在当前动态字体配置下的字体大小显示出来。
这个练习能帮助你把所学的知识结合起来并灵活运用,你可以把实现的代码通过 PR 的方式来提交,有问题可以写到下面的留言区哦。我们下一讲将介绍如何定义语义色来支持深色模式。
源码地址:
自定义字体集合的文件地址:https://github.com/lagoueduCol/iOS-linyongjian/blob/main/Frameworks/DesignKit/src/Font/UIFontExtensions.swift
14 功能组件:如何使用语义色,支持深色模式?
从 iOS 13 开始,用户可以从系统级别来把外观模式改成深色模式(Dark mode)。与原有的浅色模式(Light mode)相比,使用深色模式具有以下几大优点:
-
由于减少发光,使用深色模式能大幅减少电量的消耗,延长 iPhone 的续航能力;
-
对视力不佳或者与对强光敏感的用户更为友好,为他们提供更好的可视性;
-
在暗光环境下,让用户使用手机时更舒服。
那么,我们的 App 怎样才能在支持深色模式呢?下面我将结合咱们的项目案例 Moments App 来介绍下。
iOS 语义色
对于深色模式的支持,苹果推荐使用语义化颜色(Semantic colors)来进行适配。什么叫语义化颜色呢?语义化颜色是我们根据用途来定义颜色的名称,例如使用在背景上的颜色定义为background
,主文本和副文本的颜色分别定义为primaryText
和secondaryText
。UI 可以通过语义色来灵活地适配用户所选择的外观模式,比如背景在浅色模式下显示为白色,而在深色模式下显示为黑色。
为了简化深色模式的适配过程,苹果公司提供了具有语义的系统色(System colors)和动态系统色(Dynamic system colors)供我们使用。
iOS 系统色 (来源:developer.apple.com)
上图是苹果开发者网站提供的一个 iOS 系统色,有蓝色、绿色、靛蓝、橙色、黄色等,它们在浅色模式和深色模式下会使用到不同的颜色值。比如蓝色,在浅色模式下,它的 RGB 分别是 0、122、255,在深色模式下则分别为 10、132、255。这样就能保证系统蓝色在不同的外观模式的背景颜色上都能清晰显示。
iOS 动态系统色 (来源:developer.apple.com)
上图显示是 iOS 系统提供的动态系统色的定义。它们都是通过用途来定义各种颜色的名称。例如 Label 用于主标签文字的颜色,而 Secondary label用于副标签文字的颜色,使用它们就能自动支持不同的外观模式了。
Moments App 的语义色
为了增强品牌效果,我们一般都会为 App 单独定义一组语义色。下面以 Moments App 为例看看如何在代码中定义语义色。
根据 07 讲的设计规范,我们在 DesignKit 组件里面自定义了一组语义色,具体代码如下:
public extension UIColor {
static let designKit = DesignKitPalette.self
enum DesignKitPalette {
public static let primary: UIColor = dynamicColor(light: UIColor(hex: 0x0770e3), dark: UIColor(hex: 0x6d9feb))
public static let background: UIColor = dynamicColor(light: .white, dark: .black)
public static let secondaryBackground: UIColor = dynamicColor(light: UIColor(hex: 0xf1f2f8), dark: UIColor(hex: 0x1D1B20))
public static let tertiaryBackground: UIColor = dynamicColor(light: .white, dark: UIColor(hex: 0x2C2C2E))
public static let line: UIColor = dynamicColor(light: UIColor(hex: 0xcdcdd7), dark: UIColor(hex: 0x48484A))
public static let primaryText: UIColor = dynamicColor(light: UIColor(hex: 0x111236), dark: .white)
public static let secondaryText: UIColor = dynamicColor(light: UIColor(hex: 0x68697f), dark: UIColor(hex: 0x8E8E93))
public static let tertiaryText: UIColor = dynamicColor(light: UIColor(hex: 0x8f90a0), dark: UIColor(hex: 0x8E8E93))
public static let quaternaryText: UIColor = dynamicColor(light: UIColor(hex: 0xb2b2bf), dark: UIColor(hex: 0x8E8E93))
static private func dynamicColor(light: UIColor, dark: UIColor) -> UIColor {
return UIColor { $0.userInterfaceStyle == .dark ? dark : light }
}
}
}
public extension UIColor {
convenience init(hex: Int) {
let components = (
R: CGFloat((hex >> 16) & 0xff) / 255,
G: CGFloat((hex >> 08) & 0xff) / 255,
B: CGFloat((hex >> 00) & 0xff) / 255
)
self.init(red: components.R, green: components.G, blue: components.B, alpha: 1)
}
}
我们为UIColor
定义了一个类型扩展(Extension)。为了调用时具有命名空间,我们在这个扩展里定义了一个名叫DesignKitPalette
的内嵌枚举类型(Nested enum),然后定义了一个静态属性来引用该枚举。
首先,我们一起看看DesignKitPalette
两个公用的方法。第一个是func dynamicColor(light: UIColor, dark: UIColor) -> UIColor
,在该方法里面,我们根据用户当前选择的userInterfaceStyle
来返回对应的深色或者浅色。
第二个方法是通过类型扩展来为UIColor
类型添加了一个初始化函数(构造函数)。该初始化函数接收一个Int
类型的参数,这个参数保存了一个十六进制的值。函数内部从hex
里面取出分别表示红色、绿色和蓝色的R
、G
和B
的值,例如传入的hex
是0x0770e3
,那么R
、G
和B
的值是分别是07
、70
和e3
, 然后把这些值传递给原有的init(red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat)
初始化函数来生成一个UIColor
的实例。
有了这两个函数以后,我们就可以很方便地定义设计规范里面的各种颜色了。具体来说,只需要把浅色和深色传递给语义色的属性就可。比如,我们的语义色primary
所对应的浅色和深色的十六进制分别是0x0770e3
和0x6d9feb
,那么我们就可以通过这两个值来生成一个支持动态颜色的 UIColor 对象,代码如下所示。
public static let primary: UIColor = dynamicColor(light: UIColor(hex: 0x0770e3), dark: UIColor(hex: 0x6d9feb))
有了这些定义以后,我们可以在代码中很方便地使用它们。代码如下:
label.textColor = UIColor.designKit.primaryText
view.backgroundColor = UIColor.designKit.background
可以看到,我们可以通过UIColor.designKit
取出相应的语义色并赋值给类型为UIColor
的属性即可。
测试语义色
当我们的 App 使用了语义色以后,要经常在浅色和深色模式之间来回切换,加以测试,及时发现问题解决问题。要不然在开发过程中可能会因为不小心引入影响可读性的 Bug ,从而降低用户体验。幸运的是,iOS 的 Simulator 为我们提供了一组快捷键Command + Shift + A来快速切换外观模式。下面是 Moments App 在不同外观模式下运行的效果。
从视频上你可以看到,当我按下快捷键Command + Shift + A的时候 Moments App 在浅色和深色模式之间自动来回切换。这样能帮我们快速检查界面上文本的可读性。
总结
在这一讲中我介绍了如何通过语义色来灵活支持不同的外观模式,同时以 Moments App 为例子介绍了如何通过UIColor
的扩展来自定义语义色。
当我们的 App 使用了语义色以后,还需要注意以下几点。
-
不要把深色模式等于黑夜模式或者夜间模式,支持深色模式的 App 在正常光线的环境下也要为用户提供良好的视觉舒适度。
-
App 应该从系统设置里面读取外观模式的信息,而不是让用户在 App 里面进行单独配置。
-
在开发过程中,要经常切换外观模式来测试 App。
-
要在设置 App->辅助功能->显示与字体大小页面中修改降低透明度和增强对比度开关,检查深色内容在黑色背景下的可读性。
思考题:
除了上述通过代码的方式以外,我们还可以在资源目录(Asset Catalog)中添加语义色。请问这两种办法各有什么优缺点?
可以把你的想法和答案写到下面的留言区哦,我们下一讲将介绍如何通过 BFF 设计跨平台的系统架构。
源码地址: