Apple声称鼓励第三方App能够支持动态文本。但是,如果你尝试在App中实现这个特性,你会发现其中有很多坑(例如静态cell和定制cell样式)。在本文中,我们将介绍动态文本的机理以及它在各种场景中的应用。我们也会介绍一些Swift代码,这将极大地帮助你在自己的App中实现动态文本。
什么是动态文本?
在iOS7中,Apple引入了动态文本的概念。动态文本允许用户通过设置程序修改App的字体大小(只是针对支持动态文本的App)。
对于视力不好的用户,很容易就能将文本字体增大,另一方面,对于视力较好的用户,则可以将字体改小,以便在同一屏中容纳更多的内容。
要在设置App中修改动态文本设置,选择通用->辅助功能->更大字体,如图1所示。用户通过拖动滑条来改变字体的大小。要使用更大的字体,可以打开屏幕上方的“辅助功能中的更大字体”开关。
图 1 – 更大字体设置
图2左边的图显示的是联系人App在最小字体下的显示效果,右边的图是在没有打开“辅助功能中的更大字体”时的最大字体的显示效果。
图 2 – 联系人 app 的小字体和大字体
下面是系统内置的支持动态文本的App:
- 信息
- 日历
- 地图
- 备忘录
- 健康
- 事项提醒
- 联系人
- 天气
正是因为这些App都支持动态文本,用户也会要求第三方的App也支持动态文本。先让我们看看最终的效果。
运行示例App
我们先把项目check out出来。你可以在[这里](http://www.iosappsfornonprogrammers.com/media/blog/iDeliverMobileDynamicType.zip)下载示例项目。
- 在Xcode中,打开iDeliverMobile.xcodeproj文件。
- 运行程序之前,打开Xcode->Open->Developer Tool->iOS Simulator菜单。
- 在模拟器菜单中,选择Hardware->Device->iPhone 5S。
- 然后,打开设置程序,找到General->Accessibility->Larger Text。将滑块向右拖动至最大。
- 回到Xcode,点击Scheme,从设备列表中选择iPhone 5S。
- 点击Run,在模拟器中运行程序,你会看到字体仍然是小字体。显然,我们的App还不支持动态文本。
- 回到Xcode,退出App。
使用文本样式
当前,示例程序中的所有UI控件的字体名称和大小都是硬编码的。要支持动态文本,我们需要将这些硬编码的内容替换成文本样式。
文本样式是类似文字处理程序中的”样式“的概念。样式能够让我们以相对大小和字重的方式指定某段文本的字体。图3列出了可选的字体风格。
图 3 – 动态文本中使用的文本样式
让我们先来试一试。
- 在项目导航窗口中,选择Main.storyboard文件。
- 在Deliveries场景中,选择Feng Wong标签,打开属性面板,将Font修改为Headline(图4)。
图 4 – 设置Font 为 Headline. - 选择address标签,将Font设置为Subhead。
- 要预览全部动态文本效果,将address标签的AutoShrink设为Fixed Font Size(图5)。这会让address标签的text被截断,但随后我们会解决这个问题。
图 5 – 设置address 的 Font 为 Subhead , Autoshrink 为 Fixed Font Size - 点击Run,在模拟器中运行程序,你会发现,字体的大小已经发生改变(图6)。
图 6 – 动态文本已经起作用了
注意,为了适应大文本,行高被稍微增高了一点。
现在,让我们来看看,当用户在设置程序中改变字体大小后,又会发生什么情况?
- 如果 iDeliverMobileCD 未启动,请点击Xcode中的Run按钮。
- 当App一启动,按 Shift+Command+H ,切到Home屏幕。
- 打开设置程序,找到 General -> Accessibility -> Larger Text ,将滑块向左拖动,以减少字体大小。
- 按下 Shift+Command+H ,回到Home屏,重新打开iDeliverMobileCD 。注意标签的字体已经变小了(图7)。
Figure 7 – The fonts change dynamically. - 回到Xcode ,退出程序。
动态文本和模板单元格
你看到的例子实际上相当于我们进行了以下动作:
- 将Table view 的Content 设为Dynamic Prototype(动态模板)
- 将Cell的style设为任意一种内置类型
就如你在图5中所见,示例中的Table View确实使用了模板单元格。
如果你选择Deliveries场景中的Table View中的单元格,在属性面板中你会看到其style是Subtitle(图8)。
图 8 – 单元格的Style 为 Subtitle.
呆会你会明白,Table View的静态单元格和动态单元格是截然不同的。
标签文本的换行
在一些iOS内置应用中,苹果允许文本在加大字体后被截断。在联系人应用中,你会在email地址中看到这样的例子(图9)。
图 9 – email 地址被截断
在你的App中,你可以允许文本被截断,或者换到下一行。现在,让我们看看如何换行。
在Deliveries场景中,选择detail text标签,打开属性面板,将number of lines设置为0。这会导致email地址换行(图10)。
图 10 – 标签文字超出了行高
然而,iOS却不能正确地计算行高。接下来我们就来讨论动态单元格的行高。
动态行高
当在Table View中使用动态文本时,表格的行高必须也能够自动适应字体大小的变化。苹果提供了3种解决办法:
- 修改表格的rowHeight属性。
- 实现tableView:heightForRowAtIndexPath: 协议方法。
- 自适应大小的单元格。
使用rowHeight属性
尽管你的表格的行高应该是动态计算的,但你仍然可以像过去一样使用rowHeight属性。每当字体大小改变(后面我们会讲到如何获得相应的通知),我们都需要重新计算新的行高,并设置表格的rowHeight属性。
使用rowHeight属性的优点是速度。它提供了最优的滚动性能,因为当用户滚动表格时,不需要进行任何计算。
缺点是我们必须手动计算正确的行高。另外,所有的单元格都必须使用相同的行高。
在iOS7中,默认行高为44,在iOS8中,默认行高是UITableViewAutomaticDimenssion(一个常量,等于-1)。如果你要使用rowHeight属性,你需要在属性面板中或者viewDidLoad方法中设置它的初始值。
实现heightForRowAtIndexPath方法
我们可以用tableView:heightForRowAtIndexPath: 方法单独计算每一行的行高。
这种方法没有什么明显的优点。每一行的行高都会事先被询问,不管该行是不是已经被创建。如果你的表格有上千行,这会导致性能上的延迟。
自适应大小单元格
如果使用自适应大小单元格,而不是使用rowHeight属性,则我们既不用设置estimatedRowHeight属性,也不用实现tableView:estimatedHeightForRowAtIndexPath:协议方法。
创建自适应大小单元格的步骤大致如下:
- 在绘制每个单元格之前,它会使用estimatedRowHeight属性或者调用相关的委托方法。
当表格滚动,该行即将显示到屏幕时,单元格被创建。
此时单元格会被询问其大小。
- 如果这个高度和estimatedHeight不同,则使用该高度进行调整。
- 显示单元格。
在第3步,又有两种计算单元格高度的方式:
- 自动布局
- 手动计算大小
Table View会调用每个单元格的systemLayoutSizeFittingSize方法。该方法返回单元格是否已经实现了布局约束,如果实现,则自动布局引擎负责指定单元格的大小。
如果没有实现自己的布局约束,TableView调用单元格的sizeThatFits方法。在这个方法中我们可以自行计算单元格高度并返回——而单元格的宽度是已经计算好的。
在动态文本中使用自动布局
先让我们在示例项目中试下自动布局,看如何在动态文本中使用。首先需要确定故事板是否支持自动布局。
- 点击故事板的白色背景
- 打开文件面板。在Interface Builder Document栏下,确保Use Auto Layout 已选中(图11)。
图 11 -选中 Use Auto Layout 将Deliveries场景的表格单元格的风格修改为自定义。选中表格单元格,打开属性面板,将Sytle设置为Custom。这会将单元格的两个标签删除。
在IB中改变单元格的高度是非常简单的。点击表格的灰色区域,在Size面板,将 Row Height设置为 60。
从Object Library中拖一个标签到单元格中,你可以看到它的水平和垂直导线,如图12所示。
图 12 -加一个标签到单元格中- 拖住标签右边的resizing手柄,将它向右拖,直到垂直导线到达图13中所示的位置。
图 13 – 设置标签的宽度 - 保持标签选中状态,打开属性面板,设置Font为Headline,Tag为1。
- 再拖一个标签放到第一个标签下方,使其导线如图14所示。
图 14 – 添加第2个标签 - 拖住标签右边的resizing手柄,向右拖,直到其垂直导线和第一个标签的垂直导线对齐。
- 保持标签选中,打开属性面板,设置Font为Subhead,Lines为0,Tag为2。将Lines设置为0,这样当标签显示长文本时会自动换行显示。
- 选中单元格上面的Headline标签。
- 点击IB编辑器右下角Pin按钮(图15),在弹出的窗口中,反选Constrain to margin勾选框,然后点击窗口上半部分的4个边距,使其显示为4条红线。这使得标签的上、下、左、右4条边都分别向最近的控件对齐。然后将Height勾选,再点击Add 5 Constraints按钮。
图 15 – 为上端的标签添加布局约束 - 选择单元格下方的Subhead标签,点击Pin按钮。
- 和Headline标签所做的一模一样:反选Constraints to margin选项,选择4边对齐,勾选Heightg,然后点击Add 5 constraints按钮。
- 继续选中Subhead标签,打开Size面板。
点击Height约束右边的Edit按钮(图16),将operator改为”great than or equal to“。这将使标签的高度自动和文本的行数匹配。
图 16 – 修改Height约束的operator - 然后对Heading标签是重复上面的操作。点击Heading标签,在Size面板,点击Height约束的Edit按钮,将operator修改为”greater than or equal to“。
- 然后修改Table View Controller的代码。选择DeliveriesViewController.swift文件。在tableView:cellForRowAtIndexPath:方法中,将代码修改为:
这段代码用viewWithTag方法获得指定Tag值的标签。在代码中高亮的部分,最后一行是不能少的,因为标签某些时候会无法正确换行,因此需要将prefferedMaxLayoutWidth属性设置为当前的宽度以解决这个问题。 - 还有几个地方需要改。拉到DeliveriesViewController.swift文件顶部,在viewDidLoad方法中加入代码:
将表格的estimatedRowHeight属性设为单元格的正确高度。将表格的rowHeight属性设置为UITableViewAutomaticDimenssion,告诉iOS我们需要它自动调整单元格大小。让我们来看看运行效果! - 回到模拟器,点开设置程序,将文本大小设为最大。点击Xcode的Run按钮,当程序运行,可以看到email地址已经换行了(图17)!
图 17 – 邮箱地址换行
字体在自定义单元格上的改变
现在,当Deliveries场景第一次加载时,表格中的标签采用用户在设置程序中已经设好的字体大小显示。显然,当单元格采用内置的Subtitle样式时,如果用户改变了字体大小,则标签上的字体大小也会随之改变。但不幸的是,如果使用的是自定义单元格,这个机制就无效了。我们先来测试一下。
- 点击Run按钮。
- 当App启动后,按Shift+Command+H键退回到Home屏。
- 点开设置程序,进入General->Accessibility->Larger Text界面,将滑条向左拖动,将字体大小改小。
- 按下Shift+Command+H键,返回Home屏幕,切换到iDeliverMobileCD程序。我们发现,标签文本的字体没有发生丝毫改变。关闭App,我们来解决这个问题。
要让自定义单元格中的标签(或其他任何文本控件)能够根据设置程序中的字体大小来改变其文本字体,我们必须:
在viewDidLoad方法中向通知中心注册UIContentSizeCategoryDidChangeNotification通知。
在代码中响应字体改变通知,将标签的样式重新设置正确。例如:
在ViewController的deinit方法中注销通知。
让我们以Deliveries为例进行演示。
- 打开DeliveriesViewController.swift文件,在viewDidLoad方法最后加入:
上述代码让通知中心在用户改变了动态文本设置之后调用handleDynamicTypeChange方法。
- 在viewDidLoad方法下面新增方法:
在这个方法中重新加载Table View。
-
现在在tableView:cellForRowAtIndexPath:方法最后加入代码:
这段代码重新设置标签的字体风格。
最后,在viewDidLoad方法下面增加deinit方法:
让我们测试一下上述代码。点击Run按钮,当App启动后,我们将看到标签文本变成了先前改变的小字体。按下Shift+Command+H键回到Home屏。
打开设置程序,进入General->Accessibility->Larger Text界面,将滑块向右拖到,调大字体。
按下Shift+Command+H键,回到Home屏,切到iDeliverMobileCD程序。我们将看到,标签文本已经在没有重启App的前提下变大了!
回到Xcode,终止程序。
这种方法的弊端
这种方法有以下几个弊端:
- 我们必须每个控件都要设置两次字体。一次是在属性面板,一次是在代码中。
- 我们必须为每个文本控件都创建一个IBOutlet,哪怕你根本不需要使用这些IBOutlet。
- 我们必须在每个View Controller中增加同样的代码。
每当我们需要在不同的地方重复加入冗余的代码时,我们就应该考虑创建一种通用的解决方法以在所有项目中重用代码。
我已经创建了几个类,你可以在自己的项目中更容易地实现动态文本。在测试运行之前,先移除我们在前面添加的代码。
从viewDidLoad方法中移除下列代码:
从viewDidLoad方法下移除该处理方法:
从tableView:cellForRowAtIndexPath:方法中移除下列代码:
删除位于viewDidLoad下面的deinit方法:
更好的解决方案
现在来看看更好的解决方案。
在项目导航窗口,右键点击Main.storyboard,选择Add Files to iDeliverMobileCD…。
在添加文件对话框,反选Copy items if needed。
在项目文件夹,选择mmDynamicTypeExtensions.swift文件,然后点击Add。等一会我们在查看代码,现在先看一下如何在设计时和运行时使用这些代码。
在项目导航窗口,选中Main.storyboard。在Deliveries场景,选择单元格中位于上方的Heading Label。
-
打开属性面板,注意,显示了一个新的Type Observer属性(图18)。
图 18 – Type Observer 属性刚才添加到项目中的代码为标签添加了一个Type Observer属性。
将Type Observer属性设置为On。
选择Subhead标签,在属性面板,将Type Observer属性设置为On。
所有工作完成,让我们来测试一下。点Run按钮,当程序启动后,我们将看到显示了先前我们设置的大字体文本。按下Shift+Command+H键回到Home屏。
打开设置程序,进入General->Accessibility->Larger Text界面。将滑块向左拖动以减小字体。
按下Shift+Command+H返回Home屏,切回iDeliverMobileCD程序。你会看到,标签字体大小已然改变!
返回Xcode,终止程序。
动态文本的处理
让我们来看看代码。
在项目导航窗口,打开mmDynamicTypeExtension.swift文件。
在文件顶部,是一个协议,该协议仅包含了一个叫做typeObserver的Bool属性。也就是你在标签中设置为On的属性。
-
在协议声明之后,又定义了一个UILabel的扩展:
这个扩展声明了对DynamicTypeChangeHandler协议的实现并实现了typeObserver属性。@IBInspectable属性表明这个属性可以显示在属性面板中。这个属性的setter方法调用了动态文本管理器的registerControler方法。
-
向下滚动代码,我们可以看到DynamicTypeManager对象被实现为一个单例对象:
单例模式使得类的实例始终只有一个。当创建一个类的实例时,如果类还未被实例化,则创建新的实例。如果类已经被实例化,则返回现有的实例对象。
图19是一张序列图,显示了动态文本改变的处理逻辑。
图 19 – 动态文本处理的序列图
这是几个关键步骤:
当typeObserver属性为true时(通过属性面板中),UI控件向动态文本管理器进行注册,将一个 keypath传递给控件的字体属性。
当第一个控件进行注册时,Dynamic Type Manager实例被创建,并开始向通知中心注册动态文本改变通知。
创建一个对该控件的引用并将它的字体样式保存到一个NSMapTable中。一个Map Table是字典的一种,保存的是对象的弱引用,因此当key或value被解构时保存的对象自动被移除。这对我们来说再恰当不过了:我们并不想保持对UI控件的强引用。当UI控件释放后(例如,用户导航到另一个View Controller,当前View Contoller被解构),该控件在NSMapTable(感谢Big Nerd Ranch分享了这个技巧)中的引用将被自动移除。
当用户在设置程序中改变字体大小,通知中心会通知DynamicTypeManager对象。
DynamicTypeManager对象遍历Map Table中的UI控件,对每个控件,都设置它们的字体样式,并调用sizeToFit方法。
上图这种方式有什么好处?
它使用的是扩展而不是继承。因此我们可以使用“盒子之外的”UIKit组件。
你只需要将mmDynamicTypeExtension.swift添加到项目中就可以使用它。
- 不再需要为UI控件创建IBOutlet。只需要简单地在属性面板中设置一下就好。
这种方式使用的是松散耦合。UI控件将自己的属性提供给动态文本管理器。这意味着你注册自定义控件(或者苹果未来发布的新控件),而不需要修改动态文本管理器。
不需要在设为默认样式的模板单元格上使用这个特性,你只需要选择将哪个控件注册到动态文本管理器就行了。
动态文本和静态文本
让我们来看一下如何在静态单元格中使用动态文本。
-
在iDeliverMobileDynamicType项目中,选中Main.storyboard文件,找到Deliveries场景(图20)。
图 20 – Shipment 场景
在这个场景的Table View中,如同Deliveries场景一样包含了动态模板单元格。不同的是Shipment场景中既包含了动态文本也包含了静态文本。蓝色的文本(Phone、Text、和ID)和Status是静态的。也就是说这些文本在不同的发货单中是固定不变的。其他文本则是动态的,每个发货单都不一样。
-
要让这些标签也使用动态文本,选择每个标签,然后在属性面板中将Font设为任意一种iOS字体风格,比如:
Name – Headline
Address Line 1 – Body
Address Line 2 – Subhead
Phone labels – Body
Text labels – Body
Status labels – Body
ID labels – Body
iPod Touch label – Body -
在ShipmentViewController.swift文件中,在viewDidLoad方法最后一行加入代码:
记住,这些代码用于告诉Table View使用自适应大小单元格。
现在让我们看看效果。点击Run按钮,当程序启动,在Deliveries窗口选择shipment进入Shipment窗口。我们将看到显示的是我们先前在设置程序中设置的小字体。
现在让我们看看在程序运行的情况下App如何处理动态文本的改变。切换到设置程序,选择最大字体。再回到iDeliverMobileDynamicTypeApp。
如图21所示,所有的静态文本都不见了!这是iOS本身的一个Bug,不幸的是,在Xcode6.2中仍然未得到解决。我希望苹果以后能修正这个Bug,但目前我们不需要自定义单元格就可以解决这个问题。我们只需要在tableView:cellForRowAtIndexPath: 方法中增加一点代码去重置静态文本:
图 21 -静态文本不见了!
还有一个问题是,第一个单元格不再居中对齐。这个问题也是在同一个方法中增加代码来解决。
-
在文件的tableView:cellForRowAtIndexPath: 方法中,添加高亮部分的代码:
点击Run按钮,当程序启动,进入Shipment页面。
3.切到设置程序将字体设置为最小。回到iDeliverMobileDynamicType,我们将发现静态文本又回来了(图22)!这是因为当动态文本字体发生改变时,Table View的reloadData方法自动会调用。
图 22 – 静态文本又回来了
结语
去年,我们公司在 MacWorld 展会上有一个展台,展示我的iOS App开发图书系列。一个有弱视的读者来展位上问我,能不能教一下开发者们如何创建适用于弱视患者的App。这导致了本文的产生,我终于可以说Yes了,我希望本文能够让你在面对这个问题的时候能够同样说Yes。