Modernizing Your UI for iOS13

前言

iOS13将在2019年的秋天正式发版,每次新的iOS系统发布的时候,无数iOS开发者总是战战兢兢地祈祷新系统不会和之前的系统有太大的UI区别。

2013年发布的iOS7将iOS1~6写实化风格完全颠覆,几乎重绘了所有系统App,推出了扁平化设计。当时无数iOS开发者和UI设计师们加班加点适配新系统的场景还历历在目,想必当初加班加点的开发者们和设计师们有不少都在心中呼唤:爸爸,请给一条生路吧!

在iOS13即将发布之际,我们先来提前感受一下为了适配iOS13,我们的App都应该做些什么。好在这次苹果爸爸还算比较人道,不但没有太多额外的修改,反倒是开放了许多原本只在系统App中御用的控件,让普罗大众们也可以体验一把御用控件的快感!

好了,闲话不多扯,我们开始讲讲在iOS13上UI相关的那些事。

Flexible UI

Launch Storyboards

我们可以通过修改Launch Images的方式进行来修改一个App在启动的时候用户看到的第一个画面。在iOS8之后,苹果引入了LaunchScreen.storyboard来处理这个事情。虽然两种方式都可以正常工作,但是苹果会更加希望开发者能够使用LaunchScreen.storyboard来进行这样的操作:Launch Images需要在对应的aseets里面放入所有屏幕尺寸的Launch Images,这样每当出现一个屏幕尺寸不同的设备的时候,都需要做对应的修改,显然就不够灵活。因此在2020年4月之后,所有App都必须使用LaunchScreen.storyboard的方式来操作启动画面,否则将无法提交到AppStore进行审批。(好的,爸爸!)

Be Resizable

每次在苹果发布新屏幕尺寸的设备的时候,线上的App总是会出现界面适配的问题,为了避免这样的事情再次发生,苹果要求凡是Link了iOS13的App都需要通过特定的API适配所有尺寸的屏幕(包括屏幕尺寸最小的iPhone以及屏幕最大的iPad)。
另外,对于iPad的App,苹果也希望每个App能够支持Split Screen Multitasking。
和Launch Storyboards一样,这些特性的支持,也必须在2020年4月前完成。

iPhone适配所有尺寸

iPad适配所有尺寸

Bars

在iOS13上,默认的Navigation Bar的行为将会如下图gif所示。

iOS13 Navigation Bar

当你的App Link了iOS13之后,你就自动拥有了这些行的能力。同时,苹果还提供了额外的Appearance Customization参数来定制对应的Bar。

1
2
3
4
5
6
7
8
let appearance = UINavigationBarAppearance() 

appearance.configureWithOpaqueBackground()

appearance.titleTextAttributes = [.foregroundColor: myAppLabelColor] // 修改Navigation Bar上title的颜色
appearance.largeTitleTextAttributes = [.foregroundColor: myAppLabelColor] // 修改Navigation Bar上large title的颜色

navigationBar.standardAppearance = appearance

其中的 .standardAppearance 如下图所示,就是一个常规状态下的 Navigation Bar。

1
2
3
.standardAppearance // 常规状态
.compactAppearance // 小屏幕手机横屏时的状态
.scrollEdgeAppearance // 被ScrollView向下拉的状态

iOS13 Navigation Bar

除了 Navigation Bar 以外,UIToolBarUITabBar 也能通过类似的方式进行自定义。其中 UIToolbar 对应的类为 UIToolbarAppearanceUITabBar 对应的类为 UITabBarAppearance

另外,在 iOS13 中新的 Reminder 中,当你点击不同的类目的时候,详情页上的Navigation Bar 的 Title 颜色是会有不同的变化,如gif所示。

iOS13 Navigation Bar

这种方式应该如何实现呢?直接看代码

1
2
3
4
let appearance = navigationBar.standardAppearance.copy()
// 改变颜色
// 其他任何修改
navigationItem.standardAppearance = appearance // navigationItem拥有和navigation bar一样的属性来对应不同的状态

Presentations

在iOS13中加入了新的 UIModelPresentationStyleUIModelPresentationStyle.pageSheetUIModelPresentationStyle.formSheet,他的样子如下图所示:

iOS13 Sheet Style

于是,用户就可以使用下拉来dismiss一个ModelViewController了,掌声响起来~

再于是,默认的 UIModelPresentationStyle 就变成了UIModelPresentationStyle.automatic,如果你使用了 automatic 作为UIModelPresentationStyle,当你试图弹出的是系统为你提供的ViewController的话,系统会根据那些ViewController的配置帮你智能选择一个弹出的样式,例如:当弹出一个UIImagePickerController的时候,如果 sourceType = .photoLibrary ,那么弹出的就是一个sheet样式的照片选择界面,但是如果 sourceType = .camera 的话,弹出的就是一个全屏样式的拍摄界面。至于我们自己写的VC,如果使用 automatic 的话,默认会弹出sheet样式的界面。那么,我们如何手动指定弹出的样式呢?也非常简单,具体的代码如下:只需加上 vc.modalPresentationStyle = .fullScreen 就可以了

1
2
3
4
5
func showCustomCamera() {
let cameraVC = MyCameraViewController()
cameraVC.modalPresentationStyle = .fullScreen
present(cameraVC, animated: true)
}

至于iPad上的popover,如果你希望popover在regular width下是一个普通的VC,在compact width下是一个sheet的话,也非常简单,只需要将modelPresentationStyle设置成.popover就可以了。

iPad regular width popover

iPad compact width popover

1
2
3
4
5
6
class MyViewController: UIViewController {
func showOptions() {
let optionsVC = MyOptionsViewController()
optionsVC.modalPresentationStyle = .popover present(optionsVC, animated: true)
}
}

关于下拉关闭,一般情况下,你不需要做额外的工作,一个以sheet类型弹出的ModelVC将会默认拥有拉下关闭的操作。但是假设如果用户正在进行一些文字编辑,这时候如果进行了下拉关闭,那么我们是需要有能力让用户选择是否对已经编辑的文字做保存,放弃还是其他的操作。于是,出现了一个叫做 UIAdaptivePresentationControllerDelegate 的代理,其中:

1
2
3
func presentationControllerDidAttemptToDismiss(_:UIPresentationController) {
// Present action sheet
}

就可以在用户试图关闭一个VC的时候进行额外的操作。

对了,一个叫做 isModalInPresentation 的属性被加入了UIViewController中,只有当 isModalInPresentation 为true的时候,presentationControllerDidAttemptToDismiss 才会被调用。想要了解更信息的信息,可以参考代码:Disabling Pulling Down a Sheet

另外,对于在Share Extensions中的Principal View Controller,如果我们将isModalInPresentation设置为true,那么用户在试图关闭那个分享VC的时候,系统将会调用presentationControllerDidAttemptToDismiss,那么我们就可以在这里做一些额外的操作来确保用户编辑过的分享内容能够被更好地被处理。

敲黑板!!! 下面这部分是要考的!

对于一个以Full Screen形式弹出的VC,正如我们原先认知中了解到的一样,当一个VC被弹出的时候,将他弹出的那个VC会依次调用viewWillDisappearviewDidDisappear。然后在这个VC被dismiss的时候,将他弹出的那个VC的viewWillAppearviewDidAppear会被依次调用。如图:

FullScreen调用顺序

但是对于一个以sheet形式弹出的VC,将他弹出的那个VC的willDisappear和DidDisappear将不会被调用! 同样的,在dismiss的时候viewWillAppearviewDidAppear 也不会被调用

Sheet调用顺序

(目测这里会是一个潜在的风险!原先写在viewWillDisappear等四个函数中的代码以后都有可能会存在问题。目前可以想到的一个做法就是,如果项目中的VC都能够继承自同一个VC,那么在那个RootVC中将他的modalPresentationStyle默认先设置成fullScreen的。如果没有这样一个VC,那就只能强行hook了😂)

另外,在iOS13中,系统偷偷在UIWindowrootViewController.view中插入了一些UIKit的view,当然了,这些view是private的,苹果也表示请不要在这些view上做任何事情,因为我们不能保证这些view在不同的系统中会不会有不同的行为!

iOS13 private windows

Search

对于Search而言,UISearchViewController在iOS13中提供了更加多样的定制化功能,其中最为主要的就是把SearchBar开放出来了!(普大喜奔!!)。以后终于可以比较方便地自定义那个SearchBar了。

UISearchViewController

UISearchViewController

具体设置代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func loadView() {
let searchController = UISearchController(searchResultsController: /*...*/)

// Don’t automatically show the cancel button or scope bar
searchController.automaticallyShowsCancelButton = false
searchController.automaticallyShowsScopeBar = false

// Customize appearance of the search text field
let searchField = searchController.searchBar.textField
searchField.textColor = UIColor(named: "MyPinkColor")
searchField.font = UIFont(name: "American Typewriter", size: 18)

/* ... */
}

另外,终于可以通过设置searchController.showsSearchResultsController的方式来自定义控制是否需要显示SearchResultsController了!

同时,苹果终于将系统App中的Search Token的功能开发给开发者了,一旦设置了Search Token之后,这些Token可以被拷贝,粘贴,以及拖拽。

iOS13 Search Token

这些token有下面几个特点:

  1. 一定出现在普通文字的前面
  2. 可以被选中和删除
  3. 允许夸Token和普通文字进行选中

创建一个token的代码如下:

1
2
3
let selectedText = field.textIn(field.selectedTextRange) // "beach"
let token = UISearchToken(icon: nil, text: selectedText)
field.replaceTextualPortion(of: field.selectedTextRange, with: token, at: field.tokens.count)

iOS13 Search Token

在处理token的长度的时候,系统加入一个叫做textualRange的概念,这个将表示第一个非token的普通文字的长度,如图:

iOS13 Search Token

iOS13 Search Token

Gestures

对于UITextFieldUITextView,系统提供了自带的文字选中的功能。在iOS13中,系统增加了对自定义View的选中效果的支持,要实现这个功能也非常简单,只需要三行代码:

1
2
3
4
5
6
7
8
9
10
11
12
// Adding UITextInteractions to Your App

// Create selection interaction with type .editable or nonEditable
let selectionInteraction = UITextInteraction(for: .editable)


// Assign `textInput` property to your view that implements the UITextInput protocol
selectionInteraction.textInput = textView


// Add the interaction to the view
textView.addInteraction(selectionInteraction)

另外,对于在WWDC开幕式上演示的炫酷的双指多选的功能,在iOS13中也提供了对应的API支持(UITableViewDelegateUICollectionViewDelegate中都增加了支持):

1
2
3
optional func tableView(_ tableView: UITableView, shouldBeginMultipleSelectionInteractionAtIndexPath indexPath: IndexPath) -> Bool //是否允许多指选中

optional func tableView(_ tableView: UITableView, didBeginMultipleSelectionInteractionAtIndexPath indexPath: IndexPath) //多指选中行为发生时可以对应跟新UI等

在iOS13中,系统还支持了更多编辑的手势操作,如:三指左滑:undo,三指右滑:redo;三指缩小:copy,三指放大:paste
如果你的App中使用了UndoMananger,那么三指左滑和三指右滑的操作将会默认支持。

当然,你如果你希望你的App不支持这些默认的手势,也可以通过实现UIResponder协议中的editingInteractionConfiguration来进行:

1
2
3
4
5
6
7
8
9
10
// Working With or Without Productivity Gestures

public protocol UIResponder {
public var editingInteractionConfiguration: UIEditingInteractionConfiguration
}

public enum UIEditingInteractionConfiguration {
case `default` // System behavior, default
case none // Disable
}

好了,以后终于可以不用拿着iPad使劲摇晃来undo了!

Menus

终于,iOS上也有系统的Menus可以用啦!

iOS13 Menus

UIMenu本质上是多个UIAction的组合,当然UIMenu是可以嵌套展示的,如:

iOS13 Menus

想要在App中使用UIMenu也是非常简单的

  • 首先,创建一个UIContextMenuInteraction对象,然后将它add到对应的view中去
1
2
3
4
5
// Create a UIContextMenuInteraction with Some Delegate
let interaction = UIContextMenuInteraction(delegate: self)

// Attach It to Our View
menuSourceView.addInteraction(interaction)
  • 在View中实现对应的delegate
1
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration?
  • 在delegate方法中实现对UIMenu的配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
let actionProvider = (suggestedActions: [UIMenuElement]) -> UIMenu? {
let editMenu = UIMenu(title: "Edit...", children: [
UIAction(title: "Copy") { ... },
UIAction(title: "Duplicate") { ... }
])

return UIMenu(children: [
UIAction(title: "Share") { ... },
editMenu,
UIAction(title: "Delete", style: .destructive) { ... }
])
}

return UIContextMenuConfiguration(identifier: "unique-ID" as NSCopying, previewProvider: nil, actionProvider: actionProvider)

对于UITableViewUICollectionView,iOS13中也添加了对应的delegate来更方便地创建UIMenu

1
2
// UITableViewDelegate
optional func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAtIndexPath indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration?

总结

整体来看,App开发者们为了适配iOS13需要做的工作并不会特别多。
这一次,苹果爸爸还是很人性化地开放了不少新的控件给大家,原先一直受到诟病的如UISearchTextField无法定制化等问题,也得到了很大程度的解决。
当然,在适配iOS13中应该需要特别注意在sheet模式下viewWillDisappearviewDidDisappearviewWillAppearviewDidAppear调用时机的修改。毕竟大部分VC的这四个方法中,或多或少都实现了一些逻辑,当这些逻辑有可能不再被调用,还是会对App的行为造成不小的影响。