2025-08-05 19:38:17

iPad适配指南

「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 推出的 SlideOver、Split View 和画中画功能。从 iOS 12 开始,应用分屏的概念和操作比较接近于现在的 iPadOS。

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

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

尺寸变化

UITraitCollection 是什么

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

viewWillTransition willTransition

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

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

UITraitCollection 是什么

traitCollection是UIView,UIViewController,UIWindow,UIWindowScene和UIScreen等的属性。

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,有两种策略:

在viewWillLayoutSubviews中进行布局

可以在以下两个方法中进行布局的调整:

// 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 出现和大小变化时都会调用viewWillLayoutSubviews和willTransition

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 将界面宽度分成了 Compact 和 Regular 两种类型。

@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 变成了现在的层叠卡片样式

对于 formSheet 和 pageSheet,在 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

类型

fullScreen

pageSheet

formSheet

currentContext

overFullScreen

overCurrentContext

大小特点

覆盖全屏

更大尺寸的模态

可自定义大小的模态,默认大小如图

只覆盖当前区域

覆盖全屏

只覆盖当前区域

当然,系统也提供了 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。这里简要介绍一下:

automatic

secondaryOnlyprimaryHidden

oneBesideSecondaryallVisable

oneOverSecondaryprimaryOverlay

twoBesideSecondary iOS 14 available

twoOverSecondary iOS 14 available

twoDisplaceSecondary 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,也是有可能的。