Apple声称鼓励第三方App能够支持动态文本。但是,如果你尝试在App中实现这个特性,你会发现其中有很多坑(例如静态cell和定制cell样式)。在本文中,我们将介绍动态文本的机理以及它在各种场景中的应用。我们也会介绍一些Swift代码,这将极大地帮助你在自己的App中实现动态文本。
在iOS7中,Apple引入了动态文本的概念。动态文本允许用户通过设置程序修改App的字体大小(只是针对支持动态文本的App)。
对于视力不好的用户,很容易就能将文本字体增大,另一方面,对于视力较好的用户,则可以将字体改小,以便在同一屏中容纳更多的内容。
要在设置App中修改动态文本设置,选择通用->辅助功能->更大字体,如图1所示。用户通过拖动滑条来改变字体的大小。要使用更大的字体,可以打开屏幕上方的“辅助功能中的更大字体”开关。
图 1 – 更大字体设置
图2左边的图显示的是联系人App在最小字体下的显示效果,右边的图是在没有打开“辅助功能中的更大字体”时的最大字体的显示效果。
图 2 – 联系人 app 的小字体和大字体
下面是系统内置的支持动态文本的App:
正是因为这些App都支持动态文本,用户也会要求第三方的App也支持动态文本。先让我们看看最终的效果。
我们先把项目check out出来。你可以在[这里](http://www.iosappsfornonprogrammers.com/media/blog/iDeliverMobileDynamicType.zip)下载示例项目。
当前,示例程序中的所有UI控件的字体名称和大小都是硬编码的。要支持动态文本,我们需要将这些硬编码的内容替换成文本样式。
文本样式是类似文字处理程序中的”样式“的概念。样式能够让我们以相对大小和字重的方式指定某段文本的字体。图3列出了可选的字体风格。
图 3 – 动态文本中使用的文本样式
让我们先来试一试。
图 6 – 动态文本已经起作用了
注意,为了适应大文本,行高被稍微增高了一点。
现在,让我们来看看,当用户在设置程序中改变字体大小后,又会发生什么情况?
你看到的例子实际上相当于我们进行了以下动作:
就如你在图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属性。每当字体大小改变(后面我们会讲到如何获得相应的通知),我们都需要重新计算新的行高,并设置表格的rowHeight属性。
使用rowHeight属性的优点是速度。它提供了最优的滚动性能,因为当用户滚动表格时,不需要进行任何计算。
缺点是我们必须手动计算正确的行高。另外,所有的单元格都必须使用相同的行高。
在iOS7中,默认行高为44,在iOS8中,默认行高是UITableViewAutomaticDimenssion(一个常量,等于-1)。如果你要使用rowHeight属性,你需要在属性面板中或者viewDidLoad方法中设置它的初始值。
我们可以用tableView:heightForRowAtIndexPath: 方法单独计算每一行的行高。
这种方法没有什么明显的优点。每一行的行高都会事先被询问,不管该行是不是已经被创建。如果你的表格有上千行,这会导致性能上的延迟。
如果使用自适应大小单元格,而不是使用rowHeight属性,则我们既不用设置estimatedRowHeight属性,也不用实现tableView:estimatedHeightForRowAtIndexPath:协议方法。
创建自适应大小单元格的步骤大致如下:
当表格滚动,该行即将显示到屏幕时,单元格被创建。
此时单元格会被询问其大小。
在第3步,又有两种计算单元格高度的方式:
Table View会调用每个单元格的systemLayoutSizeFittingSize方法。该方法返回单元格是否已经实现了布局约束,如果实现,则自动布局引擎负责指定单元格的大小。
如果没有实现自己的布局约束,TableView调用单元格的sizeThatFits方法。在这个方法中我们可以自行计算单元格高度并返回——而单元格的宽度是已经计算好的。
先让我们在示例项目中试下自动布局,看如何在动态文本中使用。首先需要确定故事板是否支持自动布局。
将Deliveries场景的表格单元格的风格修改为自定义。选中表格单元格,打开属性面板,将Sytle设置为Custom。这会将单元格的两个标签删除。
在IB中改变单元格的高度是非常简单的。点击表格的灰色区域,在Size面板,将 Row Height设置为 60。
从Object Library中拖一个标签到单元格中,你可以看到它的水平和垂直导线,如图12所示。
图 12 -加一个标签到单元格中
现在,当Deliveries场景第一次加载时,表格中的标签采用用户在设置程序中已经设好的字体大小显示。显然,当单元格采用内置的Subtitle样式时,如果用户改变了字体大小,则标签上的字体大小也会随之改变。但不幸的是,如果使用的是自定义单元格,这个机制就无效了。我们先来测试一下。
要让自定义单元格中的标签(或其他任何文本控件)能够根据设置程序中的字体大小来改变其文本字体,我们必须:
在viewDidLoad方法中向通知中心注册UIContentSizeCategoryDidChangeNotification通知。
在代码中响应字体改变通知,将标签的样式重新设置正确。例如:
在ViewController的deinit方法中注销通知。
让我们以Deliveries为例进行演示。
上述代码让通知中心在用户改变了动态文本设置之后调用handleDynamicTypeChange方法。
在这个方法中重新加载Table View。
现在在tableView:cellForRowAtIndexPath:方法最后加入代码:
这段代码重新设置标签的字体风格。
最后,在viewDidLoad方法下面增加deinit方法:
让我们测试一下上述代码。点击Run按钮,当App启动后,我们将看到标签文本变成了先前改变的小字体。按下Shift+Command+H键回到Home屏。
打开设置程序,进入General->Accessibility->Larger Text界面,将滑块向右拖到,调大字体。
按下Shift+Command+H键,回到Home屏,切到iDeliverMobileCD程序。我们将看到,标签文本已经在没有重启App的前提下变大了!
回到Xcode,终止程序。
这种方法有以下几个弊端:
每当我们需要在不同的地方重复加入冗余的代码时,我们就应该考虑创建一种通用的解决方法以在所有项目中重用代码。
我已经创建了几个类,你可以在自己的项目中更容易地实现动态文本。在测试运行之前,先移除我们在前面添加的代码。
从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控件将自己的属性提供给动态文本管理器。这意味着你注册自定义控件(或者苹果未来发布的新控件),而不需要修改动态文本管理器。
不需要在设为默认样式的模板单元格上使用这个特性,你只需要选择将哪个控件注册到动态文本管理器就行了。
让我们来看一下如何在静态单元格中使用动态文本。
在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。