iOS8中的动态文本

Apple声称鼓励第三方App能够支持动态文本。但是,如果你尝试在App中实现这个特性,你会发现其中有很多坑(例如静态cell和定制cell样式)。在本文中,我们将介绍动态文本的机理以及它在各种场景中的应用。我们也会介绍一些Swift代码,这将极大地帮助你在自己的App中实现动态文本。

什么是动态文本?

在iOS7中,Apple引入了动态文本的概念。动态文本允许用户通过设置程序修改App的字体大小(只是针对支持动态文本的App)。

对于视力不好的用户,很容易就能将文本字体增大,另一方面,对于视力较好的用户,则可以将字体改小,以便在同一屏中容纳更多的内容。

要在设置App中修改动态文本设置,选择通用->辅助功能->更大字体,如图1所示。用户通过拖动滑条来改变字体的大小。要使用更大的字体,可以打开屏幕上方的“辅助功能中的更大字体”开关。
iOS8中的动态文本
图 1 – 更大字体设置

图2左边的图显示的是联系人App在最小字体下的显示效果,右边的图是在没有打开“辅助功能中的更大字体”时的最大字体的显示效果。

iOS8中的动态文本iOS8中的动态文本
图 2 – 联系人 app 的小字体和大字体

下面是系统内置的支持动态文本的App:

  • 信息
  • 日历
  • 地图
  • 备忘录
  • 健康
  • 事项提醒
  • 联系人
  • 天气

正是因为这些App都支持动态文本,用户也会要求第三方的App也支持动态文本。先让我们看看最终的效果。

运行示例App

我们先把项目check out出来。你可以在[这里](http://www.iosappsfornonprogrammers.com/media/blog/iDeliverMobileDynamicType.zip)下载示例项目。

  1. 在Xcode中,打开iDeliverMobile.xcodeproj文件。
  2. 运行程序之前,打开Xcode->Open->Developer Tool->iOS Simulator菜单。
  3. 在模拟器菜单中,选择Hardware->Device->iPhone 5S。
  4. 然后,打开设置程序,找到General->Accessibility->Larger Text。将滑块向右拖动至最大。
  5. 回到Xcode,点击Scheme,从设备列表中选择iPhone 5S。
  6. 点击Run,在模拟器中运行程序,你会看到字体仍然是小字体。显然,我们的App还不支持动态文本。
  7. 回到Xcode,退出App。

使用文本样式

当前,示例程序中的所有UI控件的字体名称和大小都是硬编码的。要支持动态文本,我们需要将这些硬编码的内容替换成文本样式。

文本样式是类似文字处理程序中的”样式“的概念。样式能够让我们以相对大小和字重的方式指定某段文本的字体。图3列出了可选的字体风格。

iOS8中的动态文本

图 3 – 动态文本中使用的文本样式

让我们先来试一试。

  1. 在项目导航窗口中,选择Main.storyboard文件。
  2. 在Deliveries场景中,选择Feng Wong标签,打开属性面板,将Font修改为Headline(图4)。
    iOS8中的动态文本
    图 4 – 设置Font 为 Headline.
  3. 选择address标签,将Font设置为Subhead。
  4. 要预览全部动态文本效果,将address标签的AutoShrink设为Fixed Font Size(图5)。这会让address标签的text被截断,但随后我们会解决这个问题。
    iOS8中的动态文本
    图 5 – 设置address 的 Font 为 Subhead , Autoshrink 为 Fixed Font Size
  5. 点击Run,在模拟器中运行程序,你会发现,字体的大小已经发生改变(图6)。

iOS8中的动态文本
图 6 – 动态文本已经起作用了

注意,为了适应大文本,行高被稍微增高了一点。

现在,让我们来看看,当用户在设置程序中改变字体大小后,又会发生什么情况?

  1. 如果 iDeliverMobileCD 未启动,请点击Xcode中的Run按钮。
  2. 当App一启动,按 Shift+Command+H ,切到Home屏幕。
  3. 打开设置程序,找到 General -> Accessibility -> Larger Text ,将滑块向左拖动,以减少字体大小。
  4. 按下 Shift+Command+H ,回到Home屏,重新打开iDeliverMobileCD 。注意标签的字体已经变小了(图7)。
    iOS8中的动态文本
    Figure 7 – The fonts change dynamically.
  5. 回到Xcode ,退出程序。

动态文本和模板单元格

你看到的例子实际上相当于我们进行了以下动作:

  1. 将Table view 的Content 设为Dynamic Prototype(动态模板)
  2. 将Cell的style设为任意一种内置类型

就如你在图5中所见,示例中的Table View确实使用了模板单元格。

如果你选择Deliveries场景中的Table View中的单元格,在属性面板中你会看到其style是Subtitle(图8)。

iOS8中的动态文本

图 8 – 单元格的Style 为 Subtitle.

呆会你会明白,Table View的静态单元格和动态单元格是截然不同的。

标签文本的换行

在一些iOS内置应用中,苹果允许文本在加大字体后被截断。在联系人应用中,你会在email地址中看到这样的例子(图9)。

iOS8中的动态文本
图 9 – email 地址被截断

在你的App中,你可以允许文本被截断,或者换到下一行。现在,让我们看看如何换行。

在Deliveries场景中,选择detail text标签,打开属性面板,将number of lines设置为0。这会导致email地址换行(图10)。
iOS8中的动态文本
图 10 – 标签文字超出了行高

然而,iOS却不能正确地计算行高。接下来我们就来讨论动态单元格的行高。

动态行高

当在Table View中使用动态文本时,表格的行高必须也能够自动适应字体大小的变化。苹果提供了3种解决办法:

  1. 修改表格的rowHeight属性。
  2. 实现tableView:heightForRowAtIndexPath: 协议方法。
  3. 自适应大小的单元格。

使用rowHeight属性

尽管你的表格的行高应该是动态计算的,但你仍然可以像过去一样使用rowHeight属性。每当字体大小改变(后面我们会讲到如何获得相应的通知),我们都需要重新计算新的行高,并设置表格的rowHeight属性。

使用rowHeight属性的优点是速度。它提供了最优的滚动性能,因为当用户滚动表格时,不需要进行任何计算。

缺点是我们必须手动计算正确的行高。另外,所有的单元格都必须使用相同的行高。

在iOS7中,默认行高为44,在iOS8中,默认行高是UITableViewAutomaticDimenssion(一个常量,等于-1)。如果你要使用rowHeight属性,你需要在属性面板中或者viewDidLoad方法中设置它的初始值。

实现heightForRowAtIndexPath方法

我们可以用tableView:heightForRowAtIndexPath: 方法单独计算每一行的行高。

这种方法没有什么明显的优点。每一行的行高都会事先被询问,不管该行是不是已经被创建。如果你的表格有上千行,这会导致性能上的延迟。

自适应大小单元格

如果使用自适应大小单元格,而不是使用rowHeight属性,则我们既不用设置estimatedRowHeight属性,也不用实现tableView:estimatedHeightForRowAtIndexPath:协议方法。

创建自适应大小单元格的步骤大致如下:

  1. 在绘制每个单元格之前,它会使用estimatedRowHeight属性或者调用相关的委托方法。
  2. 当表格滚动,该行即将显示到屏幕时,单元格被创建。

  3. 此时单元格会被询问其大小。

  4. 如果这个高度和estimatedHeight不同,则使用该高度进行调整。
  5. 显示单元格。

在第3步,又有两种计算单元格高度的方式:

  1. 自动布局
  2. 手动计算大小

Table View会调用每个单元格的systemLayoutSizeFittingSize方法。该方法返回单元格是否已经实现了布局约束,如果实现,则自动布局引擎负责指定单元格的大小。

如果没有实现自己的布局约束,TableView调用单元格的sizeThatFits方法。在这个方法中我们可以自行计算单元格高度并返回——而单元格的宽度是已经计算好的。

在动态文本中使用自动布局

先让我们在示例项目中试下自动布局,看如何在动态文本中使用。首先需要确定故事板是否支持自动布局。

  1. 点击故事板的白色背景
  2. 打开文件面板。在Interface Builder Document栏下,确保Use Auto Layout 已选中(图11)。
    iOS8中的动态文本
    图 11 -选中 Use Auto Layout
  3. 将Deliveries场景的表格单元格的风格修改为自定义。选中表格单元格,打开属性面板,将Sytle设置为Custom。这会将单元格的两个标签删除。

  4. 在IB中改变单元格的高度是非常简单的。点击表格的灰色区域,在Size面板,将 Row Height设置为 60。

  5. 从Object Library中拖一个标签到单元格中,你可以看到它的水平和垂直导线,如图12所示。
    iOS8中的动态文本
    图 12 -加一个标签到单元格中

  6. 拖住标签右边的resizing手柄,将它向右拖,直到垂直导线到达图13中所示的位置。
    iOS8中的动态文本
    图 13 – 设置标签的宽度
  7. 保持标签选中状态,打开属性面板,设置Font为Headline,Tag为1。
  8. 再拖一个标签放到第一个标签下方,使其导线如图14所示。
    iOS8中的动态文本
    图 14 – 添加第2个标签
  9. 拖住标签右边的resizing手柄,向右拖,直到其垂直导线和第一个标签的垂直导线对齐。
  10. 保持标签选中,打开属性面板,设置Font为Subhead,Lines为0,Tag为2。将Lines设置为0,这样当标签显示长文本时会自动换行显示。
  11. 选中单元格上面的Headline标签。
  12. 点击IB编辑器右下角Pin按钮(图15),在弹出的窗口中,反选Constrain to margin勾选框,然后点击窗口上半部分的4个边距,使其显示为4条红线。这使得标签的上、下、左、右4条边都分别向最近的控件对齐。然后将Height勾选,再点击Add 5 Constraints按钮。
    iOS8中的动态文本
    图 15 – 为上端的标签添加布局约束
  13. 选择单元格下方的Subhead标签,点击Pin按钮。
  14. 和Headline标签所做的一模一样:反选Constraints to margin选项,选择4边对齐,勾选Heightg,然后点击Add 5 constraints按钮。
  15. 继续选中Subhead标签,打开Size面板。
    点击Height约束右边的Edit按钮(图16),将operator改为”great than or equal to“。这将使标签的高度自动和文本的行数匹配。
    iOS8中的动态文本
    图 16 – 修改Height约束的operator
  16. 然后对Heading标签是重复上面的操作。点击Heading标签,在Size面板,点击Height约束的Edit按钮,将operator修改为”greater than or equal to“。
  17. 然后修改Table View Controller的代码。选择DeliveriesViewController.swift文件。在tableView:cellForRowAtIndexPath:方法中,将代码修改为:
    iOS8中的动态文本
    这段代码用viewWithTag方法获得指定Tag值的标签。在代码中高亮的部分,最后一行是不能少的,因为标签某些时候会无法正确换行,因此需要将prefferedMaxLayoutWidth属性设置为当前的宽度以解决这个问题。
  18. 还有几个地方需要改。拉到DeliveriesViewController.swift文件顶部,在viewDidLoad方法中加入代码:
    iOS8中的动态文本
    将表格的estimatedRowHeight属性设为单元格的正确高度。将表格的rowHeight属性设置为UITableViewAutomaticDimenssion,告诉iOS我们需要它自动调整单元格大小。让我们来看看运行效果!
  19. 回到模拟器,点开设置程序,将文本大小设为最大。点击Xcode的Run按钮,当程序运行,可以看到email地址已经换行了(图17)!
    iOS8中的动态文本
    图 17 – 邮箱地址换行

字体在自定义单元格上的改变

现在,当Deliveries场景第一次加载时,表格中的标签采用用户在设置程序中已经设好的字体大小显示。显然,当单元格采用内置的Subtitle样式时,如果用户改变了字体大小,则标签上的字体大小也会随之改变。但不幸的是,如果使用的是自定义单元格,这个机制就无效了。我们先来测试一下。

  1. 点击Run按钮。
  2. 当App启动后,按Shift+Command+H键退回到Home屏。
  3. 点开设置程序,进入General->Accessibility->Larger Text界面,将滑条向左拖动,将字体大小改小。
  4. 按下Shift+Command+H键,返回Home屏幕,切换到iDeliverMobileCD程序。我们发现,标签文本的字体没有发生丝毫改变。关闭App,我们来解决这个问题。

要让自定义单元格中的标签(或其他任何文本控件)能够根据设置程序中的字体大小来改变其文本字体,我们必须:

  1. 在viewDidLoad方法中向通知中心注册UIContentSizeCategoryDidChangeNotification通知。

  2. 在代码中响应字体改变通知,将标签的样式重新设置正确。例如:
    iOS8中的动态文本

  3. 在ViewController的deinit方法中注销通知。

让我们以Deliveries为例进行演示。

  1. 打开DeliveriesViewController.swift文件,在viewDidLoad方法最后加入:
    iOS8中的动态文本

上述代码让通知中心在用户改变了动态文本设置之后调用handleDynamicTypeChange方法。

  1. 在viewDidLoad方法下面新增方法:
    iOS8中的动态文本

在这个方法中重新加载Table View。

  1. 现在在tableView:cellForRowAtIndexPath:方法最后加入代码:
    iOS8中的动态文本

    这段代码重新设置标签的字体风格。

  2. 最后,在viewDidLoad方法下面增加deinit方法:
    iOS8中的动态文本

  3. 让我们测试一下上述代码。点击Run按钮,当App启动后,我们将看到标签文本变成了先前改变的小字体。按下Shift+Command+H键回到Home屏。

  4. 打开设置程序,进入General->Accessibility->Larger Text界面,将滑块向右拖到,调大字体。

  5. 按下Shift+Command+H键,回到Home屏,切到iDeliverMobileCD程序。我们将看到,标签文本已经在没有重启App的前提下变大了!

  6. 回到Xcode,终止程序。

这种方法的弊端

这种方法有以下几个弊端:

  1. 我们必须每个控件都要设置两次字体。一次是在属性面板,一次是在代码中。
  2. 我们必须为每个文本控件都创建一个IBOutlet,哪怕你根本不需要使用这些IBOutlet。
  3. 我们必须在每个View Controller中增加同样的代码。

每当我们需要在不同的地方重复加入冗余的代码时,我们就应该考虑创建一种通用的解决方法以在所有项目中重用代码。

我已经创建了几个类,你可以在自己的项目中更容易地实现动态文本。在测试运行之前,先移除我们在前面添加的代码。

  1. 从viewDidLoad方法中移除下列代码:
    iOS8中的动态文本

  2. 从viewDidLoad方法下移除该处理方法:
    iOS8中的动态文本

  3. 从tableView:cellForRowAtIndexPath:方法中移除下列代码:
    iOS8中的动态文本

  4. 删除位于viewDidLoad下面的deinit方法:
    iOS8中的动态文本

更好的解决方案

现在来看看更好的解决方案。

  1. 在项目导航窗口,右键点击Main.storyboard,选择Add Files to iDeliverMobileCD…。

  2. 在添加文件对话框,反选Copy items if needed。

  3. 在项目文件夹,选择mmDynamicTypeExtensions.swift文件,然后点击Add。等一会我们在查看代码,现在先看一下如何在设计时和运行时使用这些代码。

  4. 在项目导航窗口,选中Main.storyboard。在Deliveries场景,选择单元格中位于上方的Heading Label。

  5. 打开属性面板,注意,显示了一个新的Type Observer属性(图18)。
    iOS8中的动态文本
    图 18 – Type Observer 属性

    刚才添加到项目中的代码为标签添加了一个Type Observer属性。

  6. 将Type Observer属性设置为On。

  7. 选择Subhead标签,在属性面板,将Type Observer属性设置为On。

  8. 所有工作完成,让我们来测试一下。点Run按钮,当程序启动后,我们将看到显示了先前我们设置的大字体文本。按下Shift+Command+H键回到Home屏。

  9. 打开设置程序,进入General->Accessibility->Larger Text界面。将滑块向左拖动以减小字体。

  10. 按下Shift+Command+H返回Home屏,切回iDeliverMobileCD程序。你会看到,标签字体大小已然改变!

  11. 返回Xcode,终止程序。

动态文本的处理

让我们来看看代码。

  1. 在项目导航窗口,打开mmDynamicTypeExtension.swift文件。

  2. 在文件顶部,是一个协议,该协议仅包含了一个叫做typeObserver的Bool属性。也就是你在标签中设置为On的属性。
    iOS8中的动态文本

  3. 在协议声明之后,又定义了一个UILabel的扩展:
    iOS8中的动态文本

    这个扩展声明了对DynamicTypeChangeHandler协议的实现并实现了typeObserver属性。@IBInspectable属性表明这个属性可以显示在属性面板中。这个属性的setter方法调用了动态文本管理器的registerControler方法。

  4. 向下滚动代码,我们可以看到DynamicTypeManager对象被实现为一个单例对象:

    iOS8中的动态文本

    单例模式使得类的实例始终只有一个。当创建一个类的实例时,如果类还未被实例化,则创建新的实例。如果类已经被实例化,则返回现有的实例对象。

    图19是一张序列图,显示了动态文本改变的处理逻辑。

    iOS8中的动态文本
    图 19 – 动态文本处理的序列图

这是几个关键步骤:

  1. 当typeObserver属性为true时(通过属性面板中),UI控件向动态文本管理器进行注册,将一个 keypath传递给控件的字体属性。

  2. 当第一个控件进行注册时,Dynamic Type Manager实例被创建,并开始向通知中心注册动态文本改变通知。

  3. 创建一个对该控件的引用并将它的字体样式保存到一个NSMapTable中。一个Map Table是字典的一种,保存的是对象的弱引用,因此当key或value被解构时保存的对象自动被移除。这对我们来说再恰当不过了:我们并不想保持对UI控件的强引用。当UI控件释放后(例如,用户导航到另一个View Controller,当前View Contoller被解构),该控件在NSMapTable(感谢Big Nerd Ranch分享了这个技巧)中的引用将被自动移除。

  4. 当用户在设置程序中改变字体大小,通知中心会通知DynamicTypeManager对象。

  5. DynamicTypeManager对象遍历Map Table中的UI控件,对每个控件,都设置它们的字体样式,并调用sizeToFit方法。

上图这种方式有什么好处?

  • 它使用的是扩展而不是继承。因此我们可以使用“盒子之外的”UIKit组件。

  • 你只需要将mmDynamicTypeExtension.swift添加到项目中就可以使用它。

  • 不再需要为UI控件创建IBOutlet。只需要简单地在属性面板中设置一下就好。
  • 这种方式使用的是松散耦合。UI控件将自己的属性提供给动态文本管理器。这意味着你注册自定义控件(或者苹果未来发布的新控件),而不需要修改动态文本管理器。

  • 不需要在设为默认样式的模板单元格上使用这个特性,你只需要选择将哪个控件注册到动态文本管理器就行了。

动态文本和静态文本

让我们来看一下如何在静态单元格中使用动态文本。

  1. 在iDeliverMobileDynamicType项目中,选中Main.storyboard文件,找到Deliveries场景(图20)。

    iOS8中的动态文本

    图 20 – Shipment 场景

  2. 在这个场景的Table View中,如同Deliveries场景一样包含了动态模板单元格。不同的是Shipment场景中既包含了动态文本也包含了静态文本。蓝色的文本(Phone、Text、和ID)和Status是静态的。也就是说这些文本在不同的发货单中是固定不变的。其他文本则是动态的,每个发货单都不一样。

  3. 要让这些标签也使用动态文本,选择每个标签,然后在属性面板中将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

  4. 在ShipmentViewController.swift文件中,在viewDidLoad方法最后一行加入代码:

    iOS8中的动态文本

    记住,这些代码用于告诉Table View使用自适应大小单元格。

  5. 现在让我们看看效果。点击Run按钮,当程序启动,在Deliveries窗口选择shipment进入Shipment窗口。我们将看到显示的是我们先前在设置程序中设置的小字体。

  6. 现在让我们看看在程序运行的情况下App如何处理动态文本的改变。切换到设置程序,选择最大字体。再回到iDeliverMobileDynamicTypeApp。

如图21所示,所有的静态文本都不见了!这是iOS本身的一个Bug,不幸的是,在Xcode6.2中仍然未得到解决。我希望苹果以后能修正这个Bug,但目前我们不需要自定义单元格就可以解决这个问题。我们只需要在tableView:cellForRowAtIndexPath: 方法中增加一点代码去重置静态文本:

iOS8中的动态文本

图 21 -静态文本不见了!

还有一个问题是,第一个单元格不再居中对齐。这个问题也是在同一个方法中增加代码来解决。

  1. 在文件的tableView:cellForRowAtIndexPath: 方法中,添加高亮部分的代码:

    iOS8中的动态文本

  2. 点击Run按钮,当程序启动,进入Shipment页面。

3.切到设置程序将字体设置为最小。回到iDeliverMobileDynamicType,我们将发现静态文本又回来了(图22)!这是因为当动态文本字体发生改变时,Table View的reloadData方法自动会调用。

iOS8中的动态文本

图 22 – 静态文本又回来了

结语

去年,我们公司在 MacWorld 展会上有一个展台,展示我的iOS App开发图书系列。一个有弱视的读者来展位上问我,能不能教一下开发者们如何创建适用于弱视患者的App。这导致了本文的产生,我终于可以说Yes了,我希望本文能够让你在面对这个问题的时候能够同样说Yes。

上一篇:iOS8中定位服务的变化(CLLocationManager协议方法不响应,无法回掉GPS方法,不出现获取权限提示)


下一篇:iOS 学习笔记 九 (2015.04.02)IOS8中使用UIAlertController创建警告窗口