iPad 适配指南 - 基础篇

11,087 阅读11分钟

「iPad 适配指南」 这个系列会介绍在 iPad 上的一些特殊能力,如何更好地适配 iPad,以及适配 iPad 时的一些注意点。

本文作为基础篇,主要介绍 iPad 的转屏分屏、模态,和 SplitVC 能力。

如何判断 iPad 设备

如何判断设备, iPad 的各种形态

if UIDevice.current.userInterfaceIdiom == .pad {}

在 M1 Mac 上运行的 iOS 应用取到的 userInterfaceIdiom属性为 .pad

在 Mac Catalyst 上运行的应用取到的 userInterfaceIdiom 属性为 .mac

分屏适配篇

iPad 和 iPhone 最大的不同是,我们往往在 iPhone 上会限定 App 的方向恒定为 Portrait,但在 iPad 上,我们不仅要处理旋转屏,还要处理各种分屏的情况。

分屏

iOS 上的分屏最早可以追溯到随 iOS 10 推出的 SlideOverSplit View画中画功能。从 iOS 12 开始,应用分屏的概念和操作比较接近于现在的 iPadOS。

在 iPadOS 中,分屏下的应用主要有 8 种状态:横屏 1/3 屏横屏 1/2 屏横屏 2/3 屏横屏全屏竖屏 1/3 屏竖屏 2/3 屏竖屏全屏,以及悬浮窗

分屏可以通过多种操作唤起,最常见的是长按 Dock 中的图标,然后拖动到屏幕的一侧。

尺寸变化

UITraitCollection 是什么

View / VC 如何兼容大小的变化

viewWillTransition willTransition

无论是旋转屏幕,还是分屏,我们都可以收敛到「尺寸变化」这个概念上一起处理。

在此之前,需要先介绍 UITraitCollection 的概念。

UITraitCollection 是什么

traitCollectionUIViewUIViewControllerUIWindowUIWindowSceneUIScreen等的属性。

  • Transition 是指 vc 将会变化,变化的新属性集合会在 traitCollection 这个属性集合中。
  • traitCollection‌ 属性集合常用的属性有:纵横宽度的 sizeClass,是否是 darkMode 等属性

除了UIWindowScene是直接实现的属性,其他列举到的都是通过 UITraitEnvironment 协议来实现的:

public protocol UITraitEnvironment : NSObjectProtocol {

  @available(iOS 8.0, *)

  var traitCollection: UITraitCollection { get }

  /** To be overridden as needed to provide custom behavior when the environment's traits change. */

  @available(iOS 8.0, *)

  func traitCollectionDidChange( _ previousTraitCollection: UITraitCollection?)

}

traitCollectionDidChange一般会用在响应 iOS 界面环境的变化,对窗口大小变化的兼容会在接下来的一节中讲到。

View / VC 兼容大小的变化

约束布局不必考虑尺寸的变化

  • 对于 View,可以在layoutSubviews中进行 frame 布局或响应尺寸的变化。当窗口大小发生变化的时候,VC 会调用 View 的该方法。
  • 对于 VC,有两种策略:
  1. viewWillLayoutSubviews中进行布局
  2. 可以在以下两个方法中进行布局的调整:
// UIViewController 实现了这个协议

public protocol UIContentContainer : NSObjectProtocol {

  @available(iOS 8.0, *)

  func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)

  @available(iOS 8.0, *)

  func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator)

}
  • 调用时机的区别在于:

    • VC 出现和大小变化时都会调用viewWillLayoutSubviewswillTransition
    • VC 出现时,如果不主动改变 view 大小,不会调用viewWillTransition,仅当 view 的大小变化时才会调用
  • 2 中的两个函数的区别在于,窗口大小变化时:

    • willTransition会先被调用。可以通过重写该方法获得即将变成的新 traitCollection

      • 注意:此时取 view/vc/window 的 traitCollection 仍为旧值
    • viewWillTransition后被调用。可以通过重写该方法获得即将变成的新 size

      • 注意:此时取 view/vc/window 的 traitCollection 和 bounds.size 仍为旧值
      • 最佳实践:如果需要在 viewWillTransition 中获取即将变成的新 traitCollection,可以考虑在 vc 持有一个 lastTraitCollection,并且在 willTransition 时更新其值。

这三种方法不仅仅会在上述情形中被调用。

App 在 iPad 退出后台或锁屏时,因为要生成横屏和竖屏的截图以便在 App Switcher 中显示,都会被多次调用。

详见后文「锁屏/退到后台时在 iPad 上的特殊情况」

UIScreen 的使用

大家可能早已习惯直接使用 UIScreen.main.bounds。这在过去的一台设备只有唯一屏幕、一个屏幕只有唯一应用情况下是没有问题的。但事情正在发生改变:在 iPadOS 上,一个屏幕已经能显示多个应用了,在 Apple Silicon Mac 上,一个设备也能有多个显示内容不一样的屏幕,应用并不一定会在 UIScreen.main 上显示。

我们应该遵循的原则是:在每个 UIView 中,获取自身的 bounds 属性,或者利用元素间的相对关系 Auto Layout 进行布局。应该尽量避免获取设备本身的宽高来进行布局。

SizeClass 介绍

介绍 sizeClass 概念,以及各种 iOS 窗口尺寸对应的 CR 值

概念

日常我们所说的Size Class,是UITraitCollection中的两个属性:

 @available(iOS 8.0, *)

open class UITraitCollection : NSObject, NSCopying, NSSecureCoding {

  /// 水平 size class,最常用

  open var horizontalSizeClass: UIUserInterfaceSizeClass { get }

  /// 竖直 size class,用的少

  open var verticalSizeClass: UIUserInterfaceSizeClass { get }

}

Size Class 将界面宽度分成了 CompactRegular 两种类型。

 @available(iOS 8.0, *)

public enum UIUserInterfaceSizeClass : Int {

  /// 未指定

  case unspecified = 0

  /// 紧致

  case compact = 1

  /// 正常(宽松)

  case regular = 2

}

对于每个 View / VC / Window / WindowScene / Screen,都有 size class 的概念。

Size Class 对我们最重要的意义是:

响应式布局最重要的即是断点。所谓断点,就是一个分界线,在这个分界线的两边,我们会采取不同的布局策略。而 Size Class 给我们提供了关于断点的指导。

系统水平方向 Size Class 规则

  • 目前在 iPhone 竖屏时,horizontalSizeClass都是Compact,其他情况比较复杂,参考官方文档,不展开赘述;
  • 在 iPad 上,全屏横屏2/3分屏都是Regular
  • 横屏1/2分屏时,只有 12.9 寸的 iPad 是Regular
  • 除此之外的其他情况都是Compact

详见官方文档:Size Classes - HIG

布局控件篇

模态控件

介绍 modalPresentationStyle 各种样式的效果 以及着重介绍一下 popover 的概念

在 iPad 上,我们经常看到这样的页面。看起来两者差异很大,似乎需要做很多的适配,但其实代码很简单,我们只需要两行代码,就能同时完成在 iPhone 上和 iPad 上的适配:

vc.modalPresentationStyle = .formSheet

self.present(vc, animated: true)

这里涉及到了 modalPresentationStyle 的概念。

我们知道,一个 VC 可以被 push,也可以被 present。

两者在用法上的区别是,present 的页面会阻挡用户的其他操作,使其专注在当前页面上。

Sheet

在 iPad 上有两种种最常见的样式:.formSheet.pageSheet,这三种都是 present 前可以设置给 VC 的样式。

在 iPhone 上,两种 Sheet 的样式没有什么分别:

在 iPad 上 formSheet 和 pageSheet 的区别是:

  • pageSheet 的浮窗大小是系统根据系统字体大小确定的,不能修改大小
  • formSheet 和接下来要提到的 popover 的大小,都可以通过 vc 的 preferredContentSize 来指定实际大小。
pageSheet适合信息密度较高、阅读写作formSheet默认大小,适合信息密度较低或自定义大小的场景

iOS 13 对 formSheet 的窄屏样式从 fullScreen 变成了现在的层叠卡片样式

对于 formSheetpageSheet,在 iPad 上有手势下滑返回的自带功能。

如果希望介入手势下滑事件,可在 UIAdaptivePresentationControllerDelegate 中进行处理。

Popover 气泡

Popover 是 iPad 上非常常见的一种交互元素。

前面我们介绍到的 modalPresentationStyle,还有一种取值即为 .popover

但与前面几种我们提到的 Style 不同的是,除了简单的指定 modalPresentationStyle 之外,我们还需要设置几个属性:

// 指定样式

pushvc.modalPresentationStyle = .popover

// 指定 Popover 指向的矩形

pushvc.popoverPresentationController?.sourceRect = btn.frame

// 指定 Popover 指向的 View,必须指定,否则会崩溃

pushvc.popoverPresentationController?.sourceView = self.view

// 指定 Popover 允许的箭头朝向

pushvc.popoverPresentationController?.permittedArrowDirections = .up

self.present(pushvc, animated: true)

modalPresentationStyle

我们以 iPad Pro 11-inch, iOS 14, SplitVC detailVC(yellow) present(purple 40% 透明度)VC 的 case 为例,简单介绍一下所有 modalPresentationStyle 的取值区别:

横屏全屏
竖屏全屏
窄屏&iPhone
类型fullScreenpageSheetformSheetcurrentContextoverFullScreenoverCurrentContext
大小特点覆盖全屏更大尺寸的模态可自定义大小的模态,默认大小如图只覆盖当前区域覆盖全屏只覆盖当前区域

当然,系统也提供了 custom 样式,以提供自定义动画和样式的能力。

over** 的区别是:

over* 不会将覆盖的视图从视图层级撤下

iOS 15 | Customize and resize sheets in UIKit

Video: Customize and resize sheets in UIKit - WWDC 2021 - Videos - Apple Developer

在 iOS 15 中,Sheets 又有了一些新能力:

我们可以更精细化地控制 Sheets 的垂直高度了,比如创建一个半屏 Sheet,或者让 Sheet 可以在半屏高度停靠(Dedents):

我们可以移除 Sheets 下的阴影遮罩,让我们可以在展示 Sheet 的时候与下层 View 交互;

或者在 Compact 屏幕下展示非全屏 Sheet

所有的新特性都可以通过新 API:UISheetPresentationController 来进行行为的控制。

当 VC 的 modalPresentationStyle 为 formSheet / pageSheet (by default) 时,我们可以这样取得 UISheetPresentationController

// Get a sheet

if let sheet = viewController.sheetPresentationController {

  // Customize the sheet

}

present(viewController, animated: true)

路由跳转

UISplitViewController

介绍 UISplitViewController 是什么

master detail 概念

showMaster / showDetail 的概念

各种 displayMode 代表什么

为了更好地利用 iPad 更大屏幕的尺寸,系统提供了 UISplitViewController,以在宽屏情况下并列显示多个视图

上图是 iOS 14 中 UISplitViewController 更新的新接口,允许三栏同时展示。我们可以在系统自带的 邮件app 看到实际的效果。

iOS 14 更新了新的初始化接口:init(style:)。通过这个接口我们可以在初始化时设置两栏或者三栏的布局:

DisplayMode

规定术语:

Master / Primary:两栏时,展示在左侧的单栏

Detail / Secondary:两栏时,展示在右侧的详细页面

UISplitViewController 有多种显示模式,我们称之为 DisplayMode。这里简要介绍一下:

automaticsecondaryOnlyprimaryHiddenoneBesideSecondaryallVisableoneOverSecondaryprimaryOverlaytwoBesideSecondary iOS 14 availabletwoOverSecondary iOS 14 availabletwoDisplaceSecondary iOS 14 available
自动模式,根据屏幕大小自动切换只展示 detail 页Master 和 detail 并列展示Master 盖住了 detail两栏与 detail 并列两栏盖住了 detail两栏将 detail 向右挤开,参考 邮件.app

简单概括:Bseide 意为并列显示,over 意为上层会覆盖下层的一个部分,Displace 意为上层会挤开下层。

常用接口

路由行为

如果是使用 init(style:) 初始化的 iOS 14 列风格 的 SplitVC,一切会变得省心很多:

  • setViewController(_:for:) 来设置 VC 应该展示在哪一列

  • viewController(for:) 来获取指定列的 VC

  • SplitVC 会自动把所有的 childVC 用 navigationController 包住。

    • 如果设置的时候没有提供 navigationController,SplitVC 会自动创建一个。
    • 通过 SplitVC 的 children 属性可以找到 navigationController。
  • show(_:) 或者 hide(_:) 来展示或隐藏指定列

如果是传统风格的 SplitVC(只支持 master & detail 的显示,不支持更多栏):

  • 如果需要,应该手动为 master 和 detail 手动设置 navigationController 以实现路由跳转。
  • 直接设置 viewControllers 属性,默认第一个为 master,第二个为 detail,会忽略更多(如果有)
  • 使用 show(_:sender:) 来在 master 中找到 navigationController 进行 push vc
  • 使用 showDetailViewController(_:sender:) 来 在 detail 中找到 navigationController 进行 push vc
尺寸变化

在 iPad 上,用户可能进行的分屏操作会突然改变程序的视图大小。当视图较窄时,SplitVC 的分栏布局可能不再适合,我们可能需要将所有栏中的 viewControllers 进行合并。当视图变宽时,我们又需要将 viewControllers 分配到不同的列当中。在这里我们称之为 Collapse & Expand。

我们可以在 SplitVC 的 delegate 中控制上述行为:

public protocol UISplitViewControllerDelegate {

  // Return the view controller which is to become the primary view controller after `splitViewController` is collapsed due to a transition to

  // the horizontally-compact size class. If you return `nil`, then the argument will perform its default behavior (i.e. to use its current primary view

  // controller).

  @available(iOS 8.0, *)

  optional func primaryViewController(forCollapsing splitViewController: UISplitViewController) -> UIViewController?



   

  // Return the view controller which is to become the primary view controller after the `splitViewController` is expanded due to a transition

  // to the horizontally-regular size class. If you return `nil`, then the argument will perform its default behavior (i.e. to use its current

  // primary view controller.)

  @available(iOS 8.0, *)

  optional func primaryViewController(forExpanding splitViewController: UISplitViewController) -> UIViewController?



   

  // This method is called when a split view controller is collapsing its children for a transition to a compact-width size class. Override this

  // method to perform custom adjustments to the view controller hierarchy of the target controller. When you return from this method, you're

  // expected to have modified the `primaryViewController` so as to be suitable for display in a compact-width split view controller, potentially

  // using `secondaryViewController` to do so. Return YES to prevent UIKit from applying its default behavior; return NO to request that UIKit

  // perform its default collapsing behavior.

  @available(iOS 8.0, *)

  optional func splitViewController( _ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool



   

  // This method is called when a split view controller is separating its child into two children for a transition from a compact-width size

  // class to a regular-width size class. Override this method to perform custom separation behavior. The controller returned from this method

  // will be set as the secondary view controller of the split view controller. When you return from this method, `primaryViewController` should

  // have been configured for display in a regular-width split view controller. If you return `nil`, then `UISplitViewController` will perform

  // its default behavior.

  @available(iOS 8.0, *)

  optional func splitViewController( _ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController?

}

锁屏/退到后台时在 iPad 上的特殊情况

在 iOS 上,因为需要在 App Switcher 中显示各应用在横屏、竖屏、分屏情况下的界面预览,所以系统会提前在应用锁屏或退到后台时,对应用进行模拟界面变化并截图。

系统函数名为beginSnapshotSession。

在 iPad 上的整个模拟界面变化的过程中,一般会模拟横屏、竖屏、分屏等几种大小。处于最上层的 VC 可能会收到多次 willTransition / viewWillTransition / viewWillLayout 的调用。

在存在 SplitVC 的情况中,甚至因为模拟分屏,导致 mergeMasterAndDetail 时隐藏了 VC,调用到 VC 的 viewDidDisappear,也是有可能的。