「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,有两种策略:
  • 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 出现和大小变化时都会调用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,也是有可能的。

  • 私信
     2,325