iPhone Duo 화면: 배치에서 코드까지

열두 장. 같은 장면의 SwiftUI와 UIKit. 장마다 확인할 수 있는 판단 하나씩, 마지막에 탭을 아래에 두는 이유.

어떤 너비에서도 화면이 먼저 좋아 보이게 하고, 접히는 하드웨어는 그 위의 가산점으로 둡니다. iPhone Duo에는 외부 화면, 내부 화면, 그리고 힌지가 있습니다. 이것은 새로운 idiom이 아니며, 앱이 두 벌로 자라서도 안 됩니다. 아래는 학습 곡선대로 썼습니다. 각 장은 검증할 수 있는 판단 하나만 넘깁니다. 먼저 깨지는 쓰기를 보고, 왜 이렇게 설계해야 하는지 설명한 다음, 같은 장면에 SwiftUI와 UIKit을 함께 줍니다.

서명은 Xcode 27.1의 iOS 27.1 SDK와 맞춰 확인했습니다. 동작은 Designing for iPhone Duo, Preparing your app for iPhone Duo, Three steps에서 가져왔습니다. 본문은 뜻을 풀어 쓴 것이며, 원문을 그대로 옮긴 것이 아닙니다.

읽는 법. 각 장에는 먼저 「이 장을 마치면」이 있고, 이어서 깨지는 예, 올바른 쓰기, 「언제 쓰고 / 언제 쓰지 않는가」가 있습니다. 확인 문제는 먼저 스스로 답하고, 정답은 글 끝에 있습니다. 제 0–6장에서는 접힘선을 조회하지 않고, Arrangement를 쓰지 않으며, 힌지 각도를 읽지 않습니다. 그것은 뒤의 일입니다.

제 0장 · 여섯 가지 자세는 검수용이지, 여섯 벌의 화면이 아니다

이 장을 마치면. Duo가 닫히고, 펼쳐지고, 반으로 접히고, 회전할 때, 자세마다 화면을 따로 쓰지 않고 어느 너비로 생각해야 하는지 말할 수 있습니다.

깨지는 예

제품을 이렇게 나누지 마십시오. 닫힌 세로, 닫힌 가로, 펼친 세로, 펼친 가로, 책처럼 반으로 접힘, 책상 위에 세운 반접힘. 각각 뷰 트리를 따로 만듭니다. 한 번 접었다 펴면 탐색이 바뀌고, 버튼이 바뀌고, 스크롤 위치가 사라집니다.

SwiftUI로 자세를 분기하면 이렇게 됩니다.

enum Pose { case closed, open, half }
struct Root: View {
    let pose: Pose
    var body: some View {
        switch pose {
        case .closed: PhoneHome()
        case .open: TabletHome()
        case .half: BookHome()
        }
    }
}

UIKit으로 자세를 분기하면 이렇게 됩니다.

func installRoot(for pose: String) {
    let root: UIViewController
    switch pose {
    case "closed": root = PhoneHomeController()
    case "open": root = TabletHomeController()
    default: root = BookHomeController()
    }
    // 三套控制器,三份选中项
}

애플이 너비를 두 가지만 준 이유

Duo에는 외부 화면과 내부 화면이 있고, 힌지는 닫히거나, 완전히 펼쳐지거나, 중간에 멈출 수 있습니다. 사람은 세로로 들고, 가로로 들고, 책처럼 펴거나, 책상 위에 세웁니다. 이 모든 것은 한 번의 사용 안에서 이어지는 변화입니다. HIG가 요구하는 것은 이것입니다. 외부 화면은 compact 너비, 내부 화면은 regular 너비를 쓰고, 이미 있는 레이아웃이 쓸 수 있는 공간을 따라 커지게 합니다. 잡는 방법마다 앱을 다시 만들지 않습니다. 설계하는 대상은 여전히 iPhone이며, iOS의 레이아웃, 막대, 안전 영역 규칙은 계속 유효합니다.

보이는 것 그것이 무엇인가 설계할 때 어떻게 생각할 것인가
닫힘 외부 화면. 흔한 iPhone보다 더 넓고 더 낮다 compact 너비. 세로일 때 높이는 여전히 regular
완전히 펼침 큰 내부 화면, regular × regular iPad 레이아웃의 자연스러운 연장처럼 보이되, 여전히 iPhone 앱
반으로 접힘 내부 화면이 접힘선으로 양쪽이 나뉜다 여전히 같은 계층. 이 장에서는 접힘선을 다루지 않는다
회전, 화면 분할 같은 기기 위의 서로 다른 컨테이너 너비는 중간 값을 지난다. 「외부 화면 / 전체 내부 화면」 두 단계만 있는 것이 아니다

막대 방향은 이 한 문장을 먼저 기억합니다. 내부 화면을 세로로 둔 자세만이 위아래 가로 막대를 유지하는 유일한 자세입니다. 외부 화면, 그리고 내부 화면 가로에서는 시스템 막대가 옆으로 옮겨 갑니다. 화면을 나누면 각 쪽의 막대는 자기 바깥쪽에 붙습니다. 이 세부 사항은 제 5장에서 코드로 만듭니다.

올바른 구성

화면은 한 벌입니다. 상태는 레이아웃 밖에 둡니다. 너비가 바뀌면 배열만 바꾸고, 데이터는 바꾸지 않습니다.

struct Root: View {
    @State private var selection: Item.ID?
    var body: some View {
        Home(selection: $selection)
    }
}
final class RootController: UIViewController {
    let model = HomeModel()
    // 开合时只让同一个控制器重新布局
}

이 장에서 기억할 말

아직 Duo API는 없습니다. 말을 먼저 바르게 써야, 뒤에서 잘못 연결하지 않습니다.

  • 외부 화면 / 내부 화면. 닫으면 외부 화면을 보고, 펼치면 내부 화면을 봅니다. 주 장면이 스스로 화면을 바꿉니다. 언제 쓰는가: 하드웨어를 설명할 때, 그리고 검수할 때 어느 면을 시험했는지 말할 때. 언제 쓰지 않는가: 두 면을 자신이 관리하는 두 장의 캔버스로 취급하지 않습니다.

  • 자세. 닫힘, 펼침, 반접힘에 방향을 곱한 것입니다. 언제 쓰는가: Device Hub에서 하나씩 열어, 같은 화면이 아직 있는지 볼 때. 언제 쓰지 않는가: switch pose로 레이아웃을 고르지 않습니다.

  • compact / regular. 외부 화면은 compact 너비로, 내부 화면은 regular 너비로 설계합니다. 언제 쓰는가: 「한 번에 한 계층만 보는가」와 「두 계층을 동시에 보는가」를 결정할 때. 언제 쓰지 않는가: regular를 「이것은 iPad다」로 읽지 않습니다. 읽는 방법은 제 3장에 있습니다.

  • 가로 막대 / 세로 막대. 내부 화면 세로는 위아래 막대를 유지합니다. 그 밖의 흔한 자세에서는 탐색, 도구 막대, 탭 막대를 옆으로 거둡니다. 언제 쓰는가: 막대를 그릴 때 두 방향 모두 한 번씩 그려 봅니다. 언제 쓰지 않는가: 그것을 흉내 내는 가짜 사이드바를 직접 그리지 않습니다.

확인 문제

  1. 내부 화면 세로에서 시스템 막대는 위아래인가, 옆인가?

  2. 여섯 가지 자세로 화면을 몇 벌 만들어야 하는가?

  3. Duo를 펼친 뒤, 그것이 iPad 앱이 되는가?

이 장에서 아직 하면 안 되는 일. if isDuo를 쓰지 마십시오. 힌지 각도를 읽지 말고, 접힘선을 조회하지 마십시오. 지금은 「자세마다 UI 한 벌」을 거부하기만 하면 됩니다.

출처: HIG · Designing for iPhone Duo, Tech Talk 111466.

제 1장 · 넓어지면 다음 계층을 드러내고, 문장을 끝까지 늘리지 않는다

이 장을 마치면. 넓어진 영역을 받았을 때, 열을 하나 더할지, 칸을 하나 더할지, 아니면 원래의 좁은 화면을 그냥 늘릴지 결정할 수 있습니다. 무엇이 접힘선을 넘어도 되고, 무엇이 안 되는지도 말할 수 있습니다.

깨지는 예

설정 화면은 외부 화면에서 읽기 좋은 한 열입니다. 내부 화면을 펼치면 같은 목록의 각 행이 화면 전체에 가깝게 늘어납니다. 제목과 스위치가 양끝으로 갈라지고, 가운데는 넓은 빈칸이 됩니다. 또 다른 실패는 주 버튼, QR 코드, 얼굴을 화면 한가운데에 두는 것입니다. 반으로 접히면 접힘선이 그것을 가릅니다.

리뷰에 가져갈 수 있는 네 가지 원칙

  1. 기능과 상태는 끊기지 않습니다. 닫았을 때 할 수 있던 일은 펼친 뒤에도 남아 있습니다. 스크롤, 선택, 편집 중인 내용은 접고 펴는 동작 때문에 초기화하지 않습니다. 바뀌는 것은 한 번에 몇 계층을 볼 수 있는가입니다.

  2. 늘어난 너비는 계층을 드러내는 데 씁니다. 메일은 외부 화면에서 「목록이고, 들어가야 편지」입니다. 내부 화면에서는 목록과 편지를 나란히 둘 수 있습니다. 이것은 한 계층을 더 보는 것이지, 편지 행의 너비를 900pt로 만드는 일이 아닙니다.

  3. 접힘선은 책등과 같습니다. 배경, 그라데이션, 스크롤되는 글은 그 위를 지나갈 수 있습니다. 버튼, 문장, 얼굴, QR 코드, 드래그 핸들은 한가운데에 걸치지 않습니다. 이 장은 그 판단만 세웁니다. 접힘선을 어떻게 조회하는지는 제 7장입니다.

  4. 컨트롤은 그것이 작용하는 콘텐츠 가까이에 둡니다. 목록을 다루는 버튼은 목록 위에 남겨 둡니다. 옆에 세로 막대가 하나 있다고 해서, 그 버튼들을 전부 먼 곳으로 보내지 않습니다.

게임은 현재 자세를 가득 채웁니다. 위아래나 좌우에 검은 여백을 남기기보다 화면 비율을 먼저 바꿉니다. 글자와 버튼의 물리 크기는 가능한 한 유지합니다.

같은 설정 화면, 두 가지 너비

긴 글과 폼에는 읽기 좋은 상한을 줍니다. 늘어난 공간은 더 긴 문장이 아니라, 옆의 계층에 남겨 둡니다.

Form {
    Toggle("提醒", isOn: $reminders)
    Text("说明保持在容易扫读的宽度里。")
}
.frame(maxWidth: 700)
.frame(maxWidth: .infinity)
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let readable = view.readableContentGuide.layoutFrame
    formWidth.constant = min(readable.width, 700)
}

이 영역이 이미 「목록」과 「세부」를 동시에 놓을 만큼 넓다면, 다음 장에서 시스템 분할로 처리합니다. 지금은 HStack 사이드바를 직접 쓰지 마십시오. 넓어졌다고 글자 크기를 한 단계 키우지도 마십시오.

리뷰에 가져갈 질문이지, 아직 API는 아닙니다

  • 넓히기 전에 먼저 묻습니다. 지속되는 탐색, 보충 세부, 인스펙터, 미리보기, 더 많은 컨트롤, 더 높은 정보 밀도. 언제 쓰는가: compact에서 아주 넓은 쪽으로 갈 때, 이 순서로 동시에 드러낼 수 있는 구조를 찾습니다. 언제 쓰지 않는가: 빈칸을 채우려고 간격과 글자 크기를 먼저 키우지 않습니다.

  • 책등 테스트. 한가운데에 정확히 누르기 어려운 틈이 있다고 상상합니다. 넘어도 되는 것: 사진 배경, 질감, 계속 스크롤되는 목록과 글. 넘으면 안 되는 것: 버튼, 끝까지 읽어야 하는 짧은 문장, 얼굴, QR 코드, 입력 칸, 작은 아이콘.

  • 가까운 것끼리 묶습니다. 자주 쓰는 동작은 그것이 영향을 주는 덩어리 옆에 둡니다. 언제 쓰는가: 목록의 필터는 목록에, 세부 화면의 공유는 세부 화면에 둡니다. 언제 쓰지 않는가: 대칭을 맞추려고 주 동작을 콘텐츠에서 먼 모서리로 보내지 않습니다.

  • 게임은 가득 채웁니다. 자세가 바뀌면 화면 비율을 바꿉니다. 언제 쓰는가: 세로 또는 가로를 잠글 수는 있지만, 현재 자세는 가득 채워야 합니다. 언제 쓰지 않는가: 검은 여백으로 때우지 않습니다. 정말 여백을 남겨야 한다면, 단색이 아니라 화면으로 채웁니다.

확인 문제

  1. 내부 화면에서 설정 폼을 900pt까지 늘리면, 문제는 어디에 있는가?

  2. 반으로 접혔을 때, 그라데이션 배경과 「결제」 버튼 중 어느 쪽이 한가운데에 남아도 되는가?

  3. 메일 목록의 「전체 선택」은 목록을 따라가야 하는가, 가장 오른쪽 시스템 막대를 따라가야 하는가?

이 장에서 아직 하면 안 되는 일. 반접힘을 위해 페이지 전체를 다시 만들지 마십시오. 지금 접힘선을 조회하는 코드를 쓰지도 마십시오. 원고에 「한가운데에 걸치면 안 되는」 요소만 표시하면 됩니다.

제 2장 · 어느 SDK로 컴파일하는지가 화면을 얼마나 채울지 결정한다

이 장을 마치면. 배포 타깃과 컴파일 SDK를 구분하고, Duo에서 이미 열리는 패키지가 왜 여전히 검은 여백을 남기고 막대는 위에 있는지 판단할 수 있습니다.

깨지는 예

iOS 26 SDK로 만든 패키지도 설치할 수 있습니다. 내부 화면을 펼치면 창이 가운데에 있고, 사방이 비어 있습니다. 닫으면 콘텐츠가 상태 막대와 카메라의 왼쪽에 멈춥니다. 그래서 누군가 「Duo이면 frame을 끝까지 늘린다」는 패치를 또 한 벌 씁니다.

세 단계의 SDK, Duo 전용 스위치는 없다

가득 채우기와 세로 막대는 컴파일 SDK가 가져다주는 것이며, Info.plist의 불리언 하나가 아닙니다. 배포 타깃은 더 낮은 시스템에 그대로 둘 수 있습니다. 그러면 오래된 기기에도 설치됩니다. 그러나 Duo에서 화면을 가득 쓰고, 시스템 막대가 세로 형태로 들어가게 하려면, 이 패키지는 iOS 27.1 SDK 또는 그보다 새로운 SDK로 컴파일해야 합니다. 27.1 패키지를 내보내기 전에, 막대가 세로가 된 뒤에도 콘텐츠를 누를 수 있는지 먼저 확인합니다.

컴파일에 쓴 SDK 펼쳤을 때의 내부 화면 막대
iOS 26 또는 그 이전 가운데에 있고, 사방이 비어 있다. 외부 화면의 콘텐츠는 상태 막대와 카메라 왼쪽에 있다 원래의 가로 막대를 유지한다
iOS 27 내부 화면의 대부분을 채우지만, 오른쪽 상태 막대는 피한다 도구 막대는 여전히 위쪽에 있다
iOS 27.1 또는 그 이후 표시 영역을 가득 쓴다 도구 막대와 탭 막대가 상태 막대 아래에서 세로 막대로 늘어선다

배포 타깃은 더 낮아도 되고, 호출은 반드시 문을 겁니다

아래 두 조각은 아직 어떤 접힘 API도 호출하지 않습니다. 뒤의 모든 장이 지킬 습관만 만듭니다. 27.1 심볼은 가용성 검사 안에 둡니다. Mac Catalyst에서는 Xcode 27.1의 알려진 문제로 이 27.1 심볼이 컴파일되지 않을 수 있으므로, 한 번 더 갈라 둡니다.

var body: some View {
    Home()
        .modifier(DuoReadyChrome())
}

struct DuoReadyChrome: ViewModifier {
    func body(content: Content) -> some View {
        if #available(iOS 27.1, *) {
            content // 第 5 章才把垂直栏调优放进来
        } else {
            content
        }
    }
}
override func viewDidLoad() {
    super.viewDidLoad()
    if #available(iOS 27.1, *) {
        // 同一个控制器。不要在这里换 root。
    }
}

#if !targetEnvironment(macCatalyst)
// 27.1 专有调用放这里,避免 Catalyst 编不过
#endif

컴파일과 관련된 사실

  • 배포 타깃 IPHONEOS_DEPLOYMENT_TARGET. 언제 쓰는가: 설치할 수 있는 가장 오래된 시스템을 결정할 때. 언제 쓰지 않는가: 이것을 27.1로 올리는 일이 화면을 가득 채우는 전제는 아닙니다. 가득 채우는지는 컴파일 SDK를 봅니다.

  • iOS 27.1 SDK. 전체 화면과 세로 막대의 스위치입니다. 언제 쓰는가: Duo에서 완전한 형태를 넘기려 할 때 이 SDK로 컴파일합니다. 언제 쓰지 않는가: 세로 막대에 맞추기 전에 27.1 패키지를 내보내지 않습니다.

  • #available(iOS 27.1, *). 배포 타깃이 27.1보다 낮으면 새 심볼을 반드시 감쌉니다. 언제 쓰는가: 제 5장 이후의 27.1 API를 호출할 때마다. 언제 쓰지 않는가: 평범한 size class를 이것으로 감싸지 않습니다. 그것은 더 이전 시스템에도 있습니다.

  • Mac Catalyst #if !targetEnvironment(macCatalyst). 언제 쓰는가: 27.1 전용 API 때문에 Catalyst 타깃이 컴파일되지 않을 때, 그 호출을 갈라 둡니다. 언제 쓰지 않는가: 그 이유로 iPhone 쪽 적응을 지우지 않습니다. 카메라 액세서리 자체는 Catalyst에서 쓸 수 없습니다.

확인 문제

  1. 배포 타깃은 여전히 iOS 18인데, 27.1 SDK로 컴파일합니다. 세로 막대가 나타날까요?

  2. 패키지는 이미 Duo에서 실행되고, 사방이 비어 있습니다. 다음 단계는 기종을 감지해 늘리는 것인가요, SDK를 바꾸는 것인가요?

  3. 왜 27.1의 새 호출을 iOS 18 코드 경로에 그대로 쓸 수 없나요?

이 장에서 아직 하면 안 되는 일. 화면을 가득 채우려고 화면 크기를 읽지 마십시오. 다음 장에서 UIScreen.main을 바꿉니다. 세로 막대의 항목 규칙도 아직 오지 않았습니다.

출처: Three steps to make your app shine on iPhone Duo. Xcode 27.1에는 macOS Tahoe 26.6 이상이 필요합니다. 시뮬레이터는 Device Hub에서 열고 닫고, 회전하고, 접습니다.

제 3장 · 이 영역이 얼마나 넓은지 묻고, Duo인지는 묻지 않는다

이 장을 마치면. 기기, 방향, UIScreen.main으로 나눈 레이아웃 분기를 size class와 현재 컨테이너 크기로 다시 계산하게 바꿀 수 있습니다.

깨지는 예

같은 Duo가 외부 화면, 내부 화면 전체 화면, 내부 화면 분할에 나타날 수 있고, 안전 영역과 막대도 변합니다. 시작할 때 화면을 한 번 읽으면, 한 번 접는 순간 그 값은 오래됩니다. 내부 화면은 Info에 선언한 방향도 따르지 않습니다.

let tallPhone = UIScreen.main.bounds.height == 844
let scale = UIScreen.main.scale
if UIDevice.current.userInterfaceIdiom == .pad
    || horizontalSizeClass == .regular {
    IPadOnlyLayout()
}
let window = UIWindow(frame: UIScreen.main.bounds)
if UIDevice.current.orientation == .landscape { wide() }
if view.window?.windowScene?.effectiveGeometry
    .interfaceOrientation.isLandscape == true { wide() }
let width = view.bounds.width
    - view.safeAreaInsets.left * 2

반대로 기억하기 쉬운 두 가지 사실

regular 너비는 더 이상 iPad와 같지 않습니다. 내부 화면은 regular 너비, regular 높이입니다. 예전에는 「regular이면 iPad 전용 페이지」로 가던 코드가 이제 iPhone에서 실행됩니다. 애플이 요구하는 것은 이것입니다. 내부 화면은 iPad 레이아웃의 자연스러운 연장처럼 보여야 하므로, 그 레이아웃을 고칩니다. idiom 판단을 하나 더 넣어 끄지 않습니다.

방향은 모양과 같지 않습니다. 지금 가로로 긴지 세로로 긴지 알려면, 자신이 가진 그 영역의 너비와 높이를 비교합니다. 외부 화면 세로는 compact 너비에 regular 높이이고, 외부 화면 가로는 두 방향 모두 compact입니다. 내부 화면은 두 방향 모두 regular입니다. 이제 iPhone 앱은 어떤 size class 조합이든 만날 수 있습니다.

크기는 세션 도중에 변합니다. viewIsAppearing에서 한 번 적어 두고 끝까지 쓰지 마십시오.

컨테이너 기준으로 다시 계산합니다

struct Stage: View {
    @Environment(\.horizontalSizeClass) private var widthClass
    @Environment(\.verticalSizeClass) private var heightClass
    @Environment(\.displayScale) private var displayScale
    @State private var size: CGSize = .zero

    var body: some View {
        content
            .onGeometryChange(for: CGSize.self) { proxy in
                proxy.size
            } action: { newSize in
                size = newSize
            }
    }

    private var isWiderThanTall: Bool { size.width > size.height }
}
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let bounds = view.bounds
    let wider = bounds.width > bounds.height
    let scale = traitCollection.displayScale
    let usable = bounds.inset(by: view.safeAreaInsets)
    layout(for: usable.size, wider: wider, scale: scale)
}

override func viewWillTransition(
    to size: CGSize,
    with coordinator: UIViewControllerTransitionCoordinator
) {
    super.viewWillTransition(to: size, with: coordinator)
    coordinator.animate { _ in self.view.setNeedsLayout() }
}

let window = UIWindow(windowScene: scene)

정말로 UIScreen이 필요할 때는, 쓰는 그 순간에 view.window?.windowScene?.screen에서 가져옵니다. 프로퍼티로 저장하지 마십시오. 창은 scene으로 만들고, 주 화면 bounds를 frame으로 쓰지 마십시오.

API

  • horizontalSizeClass. SwiftUI: @Environment(\.horizontalSizeClass). UIKit: traitCollection.horizontalSizeClass. 언제 쓰는가: compact와 regular 너비를 구분하고, 세로 size class와 함께 볼 때. 언제 쓰지 않는가: .regular를 「iPad 페이지」로 번역하지 않습니다.

  • onGeometryChange. SwiftUI는 크기가 바뀔 때 컨테이너 CGSize를 받습니다. UIKit의 대응은 viewDidLayoutSubviews와 viewWillTransition(to:with:)입니다. 언제 쓰는가: 너비와 높이를 비교하거나, 현재 컨테이너로 부분 측정을 할 때. 언제 쓰지 않는가: 이것으로 UIScreen.main.bounds를 읽지 않습니다.

  • displayScale. SwiftUI: @Environment(\.displayScale). UIKit: traitCollection.displayScale. 언제 쓰는가: 픽셀 비율이 필요할 때. 언제 쓰지 않는가: UIScreen.main.scale을 다시 읽지 않습니다. 주 화면 참조는 폐기됩니다.

  • UIWindow(windowScene:). scene으로 창을 만듭니다. 언제 쓰는가: UIWindow(frame: UIScreen.main.bounds)를 바꿀 때. 언제 쓰지 않는가: 레이아웃 계산은 여전히 view 또는 window의 bounds를 보고, 물리 화면 전체를 보지 않습니다.

확인 문제

  1. horizontalSizeClass == .regular는 이제 iPhone에서도 성립하나요?

  2. 내부 화면을 가로로 돌렸습니다. interfaceOrientation으로 「지금 더 넓다」를 판단하는 것이 믿을 만한가요?

  3. 왜 safeAreaInsets.left * 2는 쓸 수 있는 너비를 잘못 계산하나요?

이 장에서 아직 하면 안 되는 일. 너비 임계값 if width > 700으로 탐색 트리 두 개를 전환하지 마십시오. 다음 장에서는 시스템 컨테이너로 「좁으면 밀어 넣고, 넓으면 나란히」를 해결합니다.

출처: Three steps의 Step 2, 그리고 Preparing의 resizing considerations. Tech Talk 111461.

제 4장 · 시스템 컨테이너가 나란히 둘지, 밀어 넣을지 결정하게 한다

이 장을 마치면. 분할 컨테이너 하나로 선택 항목을 지키고, 최소 항목 너비로 그리드 열 수를 정하고, 「어느 기기에 있는가」가 아니라 「어느 배열이 들어가는가」로 부분 재배치를 할 수 있습니다.

깨지는 예

compact에는 NavigationStack 하나, regular에는 직접 만든 HStack 사이드바. 선택 항목은 각자 따로 저장합니다. 그리드는 「Duo면 세 열, 아니면 한 열」로 씁니다. 버튼 그룹은 if width > 700으로 가로와 세로를 완전히 다른 두 뷰로 가릅니다.

우선순위

시스템 탐색 컨테이너를 쓸 수 있으면 컨테이너를 씁니다. 반복되는 카드는 적응형 그리드를 씁니다. 부분적으로 「가로가 안 들어가면 세로로」는 남은 공간을 시험할 수 있는 레이아웃을 씁니다. size class는 그다음입니다. 정밀한 기하는 마지막에 둡니다. 분할의 정보 구조는 「모음, 선택, 세부」입니다. 좁을 때는 목록이 세부로 들어가고, 넓을 때는 나란히 있습니다. 선택 항목은 이 변화를 견뎌야 합니다. 늘어난 너비는 이 계층을 드러내는 데 쓰고, 긴 글은 여전히 너비를 제한합니다.

목록과 세부

NavigationSplitView {
    List(items, selection: $selection) { item in
        Text(item.title)
    }
} detail: {
    if let selection {
        Detail(id: selection)
            .frame(maxWidth: 700)
    } else {
        Text("选择一项")
    }
}
let split = UISplitViewController(style: .doubleColumn)
split.setViewController(list, for: .primary)
split.setViewController(detail, for: .secondary)
// 选中项放在 list 与 detail 共用的 model 上
// preferredPrimaryColumnWidth 用 min / ideal / max
// 不要写死侧栏点数

카드 그리드: 최소 너비이지, 열 수가 아닙니다

private let columns = [
    GridItem(.adaptive(minimum: 220))
]

LazyVGrid(columns: columns, spacing: 12) {
    ForEach(cards) { Card($0) }
}
func makeLayout() -> UICollectionViewLayout {
    UICollectionViewCompositionalLayout { _, env in
        let width = env.container.effectiveContentSize.width
        let count = max(1, Int(width / 220))
        let item = NSCollectionLayoutItem(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1 / CGFloat(count)),
                heightDimension: .estimated(160)))
        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: .init(
                widthDimension: .fractionalWidth(1),
                heightDimension: .estimated(160)),
            subitem: item, count: count)
        return NSCollectionLayoutSection(group: group)
    }
}

화면 분할이 중간 너비로 자르면, 열 수는 스스로 2에 내려앉습니다. 3에서 갑자기 1로 돌아가지 않습니다. 반으로 접혔을 때 열 수를 짝수로 두고 싶다면 그것은 제 7장의 일이므로, 지금은 쓰지 않습니다.

같은 뷰 묶음은 배열만 바꾸고, 신분은 바꾸지 않습니다

ViewThatFits(in: .horizontal) {
    HStack { Preview(); Inspector() }
    VStack { Preview(); Inspector() }
}

let layout = isWiderThanTall
    ? AnyLayout(HStackLayout())
    : AnyLayout(VStackLayout())
layout {
    Chart()
    Legend()
}

UIKit에는 ViewThatFits가 없습니다. 대응하는 생각은 이것입니다. 레이아웃할 때 「가로로 아직 들어가는가」를 묻고, 같은 UIStackView의 축을 바꿉니다. 자식 컨트롤러를 없앴다가 다시 만들지 마십시오. 편집 중인 글이 사라집니다.

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let horizontal = stack.bounds.width
        > preview.intrinsicContentSize.width
        + inspector.intrinsicContentSize.width
    stack.axis = horizontal ? .horizontal : .vertical
}

API

  • NavigationSplitView. UIKit: UISplitViewController. 언제 쓰는가: 모음, 선택, 세부. 좁으면 밀어 넣고, 넓으면 나란히 둡니다. 언제 쓰지 않는가: 직접 만든 HStack 사이드바로 이것을 대신하지 않습니다. 이것은 제 8장의 두 콘텐츠 배열이 아닙니다.

  • GridItem(.adaptive(minimum:)). UIKit: 컨테이너 너비를 최소 항목 너비로 나누어 열 수를 얻습니다. 언제 쓰는가: 카드, 앨범, 대시보드처럼 반복되는 콘텐츠. 언제 쓰지 않는가: isDuo ? 3 : 1로 쓰지 않습니다.

  • ViewThatFits. 주어진 축을 따라 어느 하위 트리가 들어가는지 시험합니다. 언제 쓰는가: 버튼 그룹, 메타데이터, 미리보기와 인스펙터 같은 부분 선택. 언제 쓰지 않는가: 앱 전체의 탐색 전환에 쓰지 않습니다.

  • AnyLayout. 레이아웃 컨테이너를 바꾸되, 자식 뷰의 신분은 유지합니다. 언제 쓰는가: 차트와 범례, 미리보기와 속성처럼, 배열은 바뀌어도 여전히 그 두 뷰일 때. 언제 쓰지 않는가: 두 개의 if 가지에 자식 뷰를 각각 한 번씩 쓰지 않습니다.

확인 문제

  1. 나란히 두었다가 한 열로 접을 때, 선택 항목은 어디에 있어야 하나요?

  2. 화면을 나눈 뒤 너비가 220pt 카드 두 장만 들어갈 때, 열 수는 어떻게 나오나요?

  3. ViewThatFits가 답하는 것은 「어느 기기」인가요, 「어느 배열이 들어가는가」인가요?

이 장에서 아직 하면 안 되는 일. 탭 막대를 사이드 탐색으로 바꿀지는 다음 장에 둡니다. 두 콘텐츠가 접힘선을 피해야 하는 일은 제 8장에 둡니다. 지금은 Arrangement를 도입하지 않습니다.

제 5장 · 시스템 막대는 스스로 옆으로 서고, 가짜 막대는 서지 않는다

이 장을 마치면. 탐색, 도구 막대, 탭 막대가 가로와 세로 두 형태에서 모두 알아볼 수 있게 하고, 덜 중요한 동작은 시스템 오버플로 메뉴에 맡길 수 있습니다.

깨지는 예

페이지 위쪽은 직접 그린 HStack이거나, 따로 new한 UINavigationBar, UIToolbar, UITabBar입니다. 시스템은 이 내용을 세로 막대 안으로 거두지 않습니다. 또 흔한 문제는 공유 버튼에 제목 "Share"만 있는 것입니다. 세로 막대는 아이콘만 보여 주므로, 아이콘이 없는 항목은 아예 세로로 서지 않습니다. 직접 「…」 메뉴를 또 그리면 시스템 오버플로 메뉴와 자리를 다툽니다.

막대는 언제 세로가 되고, 누가 들어갈 수 있는가

27.1 SDK로 컴파일한 뒤, 외부 화면은 탐색 막대, 도구 막대, 탭 막대를 옆의 한 줄로 합칩니다. 내부 화면 가로, 그리고 leading / trailing 위치에 있는 일부 뷰도 이렇게 됩니다. 내부 화면 세로는 여전히 가로 막대를 씁니다. 분할에서는 세부 열만 세로 막대에 참여하고, 사이드바와 콘텐츠 열은 가로를 유지합니다. 인스펙터는 가로를 유지합니다.

막대는 하드웨어 가장자리에 맞습니다. 오른쪽에서 왼쪽으로 쓰는 언어라도 막대를 반대쪽으로 옮기지 않습니다. 화면을 나누면 왼쪽 앱의 막대는 왼쪽에, 오른쪽 앱의 막대는 오른쪽에 있습니다.

항목에는 제목과 아이콘이 둘 다 있어야 합니다. 세로 형태는 아이콘을 씁니다. 가로 형태는 아이콘을 우선하고, 제목도 쓸 수 있습니다. 오버플로 메뉴에서는 아이콘과 제목이 함께 나타납니다. 사용자 지정 뷰는 기본적으로 가로 막대에 남습니다. 세로 배치에 맞다고 선언한 경우에만 예외입니다. 글자만 있는 버튼은 가로 막대에 남습니다. 시스템의 Edit 버튼이 바로 이렇게 기호와 글자 사이를 오갑니다.

세로 막대 위쪽은 뒤로 가기나 닫기에 남겨 두고, 그다음이 Done 같은 눈에 띄는 동작입니다. 나머지 항목은 원래 그룹을 유지합니다. 오버플로는 기본적으로 아래쪽에서 위쪽으로 거둡니다. 탐색형 페이지는 탭 막대를 우선 지킵니다. 작업을 하는 페이지는 도구 항목을 우선 지킵니다.

같은 동작 묶음, 두 막대에서 모두 씁니다

SwiftUI는 NavigationStack 안에 겁니다.

NavigationStack {
    DetailView()
        .toolbar {
            ToolbarItem(placement: .cancellationAction) {
                Button("Close", systemImage: "xmark") { dismiss() }
            }
            ToolbarItem(placement: .topBarPinnedTrailing) {
                Button("Done", systemImage: "checkmark") { save() }
            }
            ToolbarItem(placement: .bottomBar) {
                Button("Share", systemImage: "square.and.arrow.up") { share() }
            }
            .visibilityPriority(.high)
            ToolbarOverflowMenu {
                Button("Duplicate", systemImage: "plus.square.on.square") {
                    duplicate()
                }
            }
        }
        .toolbarVerticalCompressionBehavior(.automatic)
}

UIKit은 탐색 컨트롤러 안의 자식 컨트롤러에 겁니다.

let close = UIBarButtonItem(
    title: "Close", image: UIImage(systemName: "xmark"),
    target: self, action: #selector(closeTapped))
let done = UIBarButtonItem(
    title: "Done", image: UIImage(systemName: "checkmark"),
    target: self, action: #selector(doneTapped))
let share = UIBarButtonItem(
    title: "Share", image: UIImage(systemName: "square.and.arrow.up"),
    target: self, action: #selector(shareTapped))
share.visibilityPriority = .high
share.axisBehavior = .automatic
navigationItem.leadingItemGroups = [
    UIBarButtonItemGroup.fixedGroup(items: [close])
]
navigationItem.pinnedTrailingGroup = UIBarButtonItemGroup.fixedGroup(items: [done])
toolbarItems = [share]
navigationItem.verticalBarCompressionBehavior = .automatic

뒤로 가기 버튼은 탐색 컨트롤러가 스스로 올립니다. 사용자 지정 닫기는 leading 쪽에 넣고, Done은 pinnedTrailingGroup에 넣습니다. 검색이 공간을 가득 쓸 때, 고정된 항목이 가장 늦게 오버플로로 들어갑니다. additionalOverflowItems는 자신의 「더 보기」를 시스템 메뉴 안으로 거두는 데 씁니다. 말줄임표는 오버플로에 남겨 두고, 다른 메뉴는 다른 기호를 씁니다.

기호와 글자가 오가는 항목은 .horizontalOnly로 둡니다. 가로 막대가 없으면 숨겨지고, 세로 막대 안으로 비집고 들어가지 않습니다. 사용자 지정 뷰에 세로로 그리는 방법이 정말로 있으면 .verticalPreferred를 씁니다. 신축 여백은 세로 축에서 크기가 0입니다. 그룹은 ToolbarItemGroup / UIBarButtonItemGroup을 쓰고, 고정 간격을 손으로 끼워 넣지 않습니다. 배지는 badge를 씁니다. 숫자를 제목에 넣지 마십시오. 그러면 이 항목이 기호만으로 바뀌기 어렵습니다.

세로 막대에서 빠지는 것은 안정된 예외입니다

NavigationStack {
    CalculatorView()
        .toolbarVerticalBehavior(.disabled)
}
override var preferredVerticalBarBehavior: UIVerticalBarBehavior {
    .disabled
}

기본값은 .automatic입니다. 이 화면에서만 빠집니다. 전체 화면 비디오, 계산기처럼 스크롤하지 않는 전체 너비 레이아웃, 닫기 버튼이 하나뿐인 sheet. 이것은 안정된 선택입니다. viewWillAppear에서 페이지 상태에 따라 왔다 갔다 하지 마십시오. 어느 한 페이지만 막대를 숨기려면 가시성 API를 쓰고, 세로 동작을 끄지 마십시오. UIKit에서 선호가 정말로 바뀌면 setNeedsUpdateOfVerticalBarConfiguration()을 호출합니다. 탐색 컨트롤러는 기본적으로 결정을 스택 맨 위에 맡기고, 탭 막대는 현재 선택된 페이지에 맡깁니다.

막대가 어느 쪽에 있는지, 그리고 배경을 어떻게 그 아래로 늘리는지

@Environment(\.toolbarVerticalEdge) private var verticalEdge
// HorizontalEdge?;这个上下文从不用垂直栏时为 nil

Image("hero")
    .backgroundExtensionEffect()
let edge = traitCollection.verticalBarEdge
// .leading / .trailing / .unspecified
// 表示系统偏好的边,不表示栏此刻是否可见
// 从不用垂直栏的上下文返回 .unspecified

let backdrop = UIBackgroundExtensionView()
backdrop.contentView = hero

UIKit에서 이 변을 감시하려면 registerForTraitChanges에 UITraitCollection.systemTraitsAffectingVerticalBarEdge를 넘깁니다. trait이 바뀌어도 변은 그대로일 수 있습니다.

1단계 입구: 내부 화면은 사이드 탐색이 될 수 있습니다

iPhone에서 .sidebarAdaptable만 쓰면 여전히 하단 막대입니다. 지원하는 곳에서 사이드 탐색으로 바꾸려면 iOS 27의 defaultTabBarPlacement(.sidebar)를 더합니다. iOS 18의 defaultAdaptableTabBarPlacement는 쓰지 마십시오. 그것은 iPad용입니다. 사이드바 관련 UI는 isTabViewSidebarAvailable을 보고, size class를 다시 추측하지 마십시오.

TabView {
    Tab("Home", systemImage: "house") { HomeView() }
    Tab("Library", systemImage: "books.vertical") { LibraryView() }
}
.tabViewStyle(.sidebarAdaptable)
.defaultTabBarPlacement(.sidebar)
tabBarController.sidebar.preferredPlacement = .sidebar

제품이 하단 막대를 분명히 유지하고, 넓어진 공간을 콘텐츠 안의 분할에 남겨도 됩니다. 둘 다 성립합니다. 너비에 따라 가짜 사이드바를 직접 그리지 마십시오.

API

  • toolbarVerticalBehavior(iOS 27.1). .automatic / .disabled. UIKit: preferredVerticalBarBehavior를 재정의하고, 타입은 UIVerticalBarBehavior입니다. 언제 쓰는가: 계산기, 전체 화면 비디오, 닫기 버튼이 하나뿐인 sheet. 언제 쓰지 않는가: 탐색이나 어떤 뷰의 상태에 따라 왔다 갔다 하지 않습니다.

  • axisBehavior(iOS 27.1). SwiftUI: .automatic / .horizontalOnly / .verticalPreferred. UIKit: UIBarButtonItem.axisBehavior, 같은 세 값입니다. 언제 쓰는가: 기호와 글자가 오가는 항목은 horizontalOnly. 세로로 그리는 방법이 분명한 사용자 지정 뷰는 verticalPreferred. 언제 쓰지 않는가: 제목과 아이콘이 있는 보통 버튼은 automatic을 유지합니다.

  • visibilityPriority(iOS 27.0). SwiftUI: .automatic / .low / .high, 그리고 init(lowerThan:) / init(higherThan:). UIKit: .standard / .low / .high. 언제 쓰는가: 먼저 묶음 전체에 우선순위를 주고, 그다음 묶음 안의 개별 항목에 줍니다. 자주 쓰는 것과 배지가 있는 것은 마지막까지 남겼다가 넘깁니다. 언제 쓰지 않는가: 두 번째 「…」 메뉴를 직접 만들지 않습니다. ToolbarOverflowMenu와 additionalOverflowItems가 시스템 메뉴입니다. UIKit의 additionalOverflowItems는 iOS 16부터 쓸 수 있습니다.

  • 압축 동작(iOS 27.1). SwiftUI: .automatic / .prefersToolbarItems / .prefersTabBar. UIKit: navigationItem.verticalBarCompressionBehavior의 .automatic / .prefersBarItems / .prefersTabBar. 언제 쓰는가: 탐색형은 기본값(도구 항목을 먼저 거두고, 탭을 지킵니다). 작업형은 탭 막대를 먼저 거두도록 바꿉니다. 언제 쓰지 않는가: 막대 두 벌을 각각 써서 압축을 흉내 내지 않습니다.

  • topBarPinnedTrailing(iOS 27.0). UIKit의 대응 프로퍼티 pinnedTrailingGroup은 iOS 16부터 쓸 수 있습니다. 닫기는 .cancellationAction을 쓰고, UIKit에서는 leadingItemGroups에 넣습니다. 언제 쓰는가: Done처럼 뒤로 가기 가까이에 있어야 하는 주 동작. 언제 쓰지 않는가: 덜 중요한 동작을 여기에 고정하지 않습니다. 그것은 넘길 수 있어야 합니다.

  • toolbarVerticalEdge(iOS 27.1). SwiftUI: HorizontalEdge?. 세로 막대를 전혀 쓰지 않는 맥락에서는 nil입니다. UIKit: traitCollection.verticalBarEdge. 값은 leading, trailing, 또는 unspecified입니다. 이것은 선호하는 변을 반영하며, 막대가 지금 보이지 않아도 그렇습니다. 언제 쓰는가: 사용자 지정 콘텐츠를 이 막대에 맞출 때. 언제 쓰지 않는가: unspecified를 「막대가 방금 숨겨졌다」로 읽지 않습니다. 그것은 이 맥락이 세로 막대를 쓰지 않는다는 뜻입니다.

  • backgroundExtensionEffect()(iOS 26). UIKit: UIBackgroundExtensionView. contentView를 안전 영역 안에 두고, 가장자리는 연장으로 채웁니다. 언제 쓰는가: 주 비주얼이나 배경을 세로 막대 아래로 늘릴 때. 언제 쓰지 않는가: 누를 수 있는 전경이 이것으로 안전 영역 밖으로 나가지 않게 합니다.

  • defaultTabBarPlacement(iOS 27.0). .sidebarAdaptable에 .sidebar를 더합니다. UIKit: sidebar.preferredPlacement = .sidebar. 환경 값 isTabViewSidebarAvailable, tabBarPlacement도 iOS 27입니다. 언제 쓰는가: 1단계 구분이 넓은 레이아웃에서 상주 탐색에 맞을 때. 언제 쓰지 않는가: Duo에 defaultAdaptableTabBarPlacement를 쓰지 않습니다. 그 수식어는 iOS 18부터 있고, 작용하는 곳은 iPad입니다.

확인 문제

  1. 제목만 있고 아이콘이 없는 버튼은 세로 막대에 들어가나요?

  2. 분할의 세 열 가운데, 어느 열의 막대가 세로가 되나요?

  3. 탐색형 페이지의 공간이 부족할 때, 기본은 도구 항목을 먼저 거두나요, 탭 막대를 먼저 거두나요?

  4. 직접 그린 UIToolbar는 왜 옆으로 서지 않나요?

이 장에서 아직 하면 안 되는 일. 접힘선을 조회해서 막대가 왼쪽인지 오른쪽인지 결정하지 마십시오. 막대의 변은 시스템이 trait / environment에 넣습니다. Sheet를 어느 쪽에 둘지는 다음 장입니다.

출처: HIG · Vertical controls. Preparing · Optimize bars. Tech Talk 111462.

제 6장 · 네 변을 따로 계산하고, Sheet는 어느 쪽에 붙을지 선언한다

이 장을 마치면. sheet의 위치를 고르고, 비대칭 안전 영역으로 쓸 수 있는 영역을 계산하고, 화면과 동심인 모서리와 초점으로 전폭 그림을 지킬 수 있습니다.

깨지는 예

왼쪽 inset에 둘을 곱해 전체 안전 영역으로 삼습니다. 화면 분할이나 세로 막대는 좌우를 다르게 만들므로, 콘텐츠가 한쪽으로 치우칩니다. 또 다른 문제는 전체 화면 가림을 직접 만들어 sheet로 쓰는 것입니다. 시스템 sheet, 경고, 메뉴, popover는 접힘선을 피하지만, 자신의 가림은 피하지 않습니다. 주 비주얼을 .fill로 자르면, 외부 화면에서 보이던 얼굴이 내부 화면에서 잘립니다.

Sheet의 막대는 배치를 따라갑니다

외부 화면에서 도구 막대가 있는 sheet는 기본적으로 세로 막대입니다. 내부 화면에서 sheet는 기본적으로 가운데에 있고 막대는 가로입니다. leading 쪽에는 세로 막대가 없고, trailing 쪽에는 있습니다. 배치를 따르는 것은 sheet뿐입니다. 이 때문에 사용자 지정 전환을 또 한 벌 만들지 마십시오. 닫기 버튼이 하나뿐인 sheet는 이전 장에서 세로 막대를 꺼도 되는 경우입니다. sourceView를 설정하면 UIKit은 preferredPlacement를 무시합니다.

.sheet(item: $selection) { item in
    ItemDetail(item: item)
        .presentationPlacement(.trailing)
}
let sheet = detail.sheetPresentationController
sheet?.preferredPlacement = .trailing
// .automatic / .leading / .center / .trailing
// sourceView 非 nil 时,这个属性被忽略

안전 영역의 네 변은 독립입니다

content
    .safeAreaPadding(.horizontal)
background
    .ignoresSafeArea()
let usable = view.bounds.inset(by: view.safeAreaInsets)
// 分别使用 left、right、top、bottom
// 折痕不是 safe area inset

장식은 안전 영역 밖으로 나갈 수 있습니다. 누를 수 있는 콘텐츠와 반드시 읽어야 하는 글은 안에 둡니다. 카메라, 세로 막대, 화면 분할은 좌우 또는 위아래를 다르게 만듭니다. 화면 모서리에 붙는 사용자 지정 도형은 동심 사각형을 쓰고, 반지름을 고정하지 마십시오.

RoundedRectangle(cornerRadius: 12) // 不要写死去贴屏幕角
ConcentricRectangle()
view.cornerConfiguration = .corners(radius: .containerConcentric())

전폭 그림은 주체를 남겨 둡니다

Image("cover")
    .resizable()
    .aspectRatio(contentMode: isWide ? .fit : .fill)
    .frame(maxWidth: .infinity, maxHeight: isWide ? 420 : 280)
    .clipped()
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let wide = view.bounds.width > view.bounds.height
    hero.contentMode = wide ? .scaleAspectFit : .scaleAspectFill
    hero.clipsToBounds = true
}

주체가 기하학적 중심에 없으면, 자르기에 초점을 줍니다. fill과 fit 사이만 오가지 마십시오. 게임은 가득 채우고 비율을 바꿉니다. 제 1장을 보십시오.

API

  • presentationPlacement(iOS 27.0). .automatic / .center / .leading / .trailing. UIKit: UISheetPresentationController.preferredPlacement. 언제 쓰는가: 모든 sheet를 닫힘, 펼침, 반접힘에서 본 뒤에, 가장자리에 붙일지 가운데에 둘지 결정합니다. 언제 쓰지 않는가: popover와 경고에는 이 수식어를 씌우지 않습니다. sourceView를 설정하면 UIKit은 이것을 무시합니다.

  • 비대칭 inset. bounds.inset(by: safeAreaInsets). 언제 쓰는가: 쓸 수 있는 너비와 높이를 손으로 계산하는 모든 곳. 언제 쓰지 않는가: 한쪽 inset을 반대쪽에 거울처럼 복사하지 않습니다. 접힘선도 안전 영역에 넣지 않습니다.

  • ConcentricRectangle(iOS 26). UIKit: UICornerConfiguration의 컨테이너 동심 반지름. UIView.cornerConfiguration이 있습니다. 언제 쓰는가: 사용자 지정 패널이 현재 화면이나 컨테이너의 모서리에 붙어야 할 때. 언제 쓰지 않는가: 카드 자신의 작은 모서리는 여전히 고정 반지름을 쓸 수 있습니다. 이것으로 접힘선을 피하지 않습니다.

  • fill / fit. 현재 비율이나 size class에 따라 고르거나, 초점을 설정합니다. 언제 쓰는가: 주 비주얼, 비디오 표지처럼, 좁은 외부 화면에서 넓은 내부 화면까지 주체를 남겨야 할 때. 언제 쓰지 않는가: 시작할 때 contentMode를 한 번만 고르지 않습니다.

확인 문제

  1. 내부 화면에서 trailing sheet의 도구 막대는 가로인가요, 세로인가요?

  2. 왜 left * 2로 콘텐츠 너비를 얻을 수 없나요?

  3. 시스템 sheet는 접힘선을 피합니다. 직접 그린 전체 화면 가림도 피하나요?

이 장에서 아직 하면 안 되는 일. 사용자 지정 캔버스, QR 코드, 드래그 컨트롤이 접힘선을 피해야 할 때, 다음 장에서야 예약 영역을 조회합니다. 지금은 접힘선 좌표를 추측하지 마십시오.

제 7장 · 접힘선과 카메라는 두 개의 예약 영역이지, 안전 영역이 아닙니다

이 장을 마치면. 사용자 지정 레이아웃에서 활성화된 접힘선과 카메라를 찾고, 가려진 그 조각만 옮길 수 있습니다. 페이지 전체를 다시 만들지 않습니다.

깨지는 예

반으로 접히는 순간 다른 화면 트리로 바꿉니다. 또는 「접힘선은 너비의 50%」라고 고정합니다. 화면을 나누거나 회전하면 이 비율은 더 이상 자신의 뷰 중심이 아닙니다. 반대쪽 실패는 글의 모든 문단을 접힘선에서 밀어 내는 것입니다. 스크롤되는 콘텐츠는 원래 그 틈을 지나갈 수 있습니다.

두 종류의 영역, 시스템 컴포넌트는 대부분 이미 피합니다

예약 영역은 다른 것이 자신의 좌표계 안에서 차지한 사각형입니다. frame은 margins를 포함합니다. 각 항목에는 종류, 활성 여부, 식별자가 있습니다. 조회가 돌려주는 것은 현재 뷰와 교차하는 영역입니다. 자신의 뷰가 내부 화면의 절반만 차지한다면, 화면 전체의 접힘선이 보인다고 가정하지 마십시오.

  • division, 분할. 반으로 접히면 힌지가 큰 덩어리를 양쪽으로 나눕니다. 완전히 평평하게 펼치면 이 영역은 활성화되지 않고, 너비는 0입니다.

  • occlusion, 가림. 외부 화면의 전면 카메라는 항상 있고, 다이내믹 아일랜드로 넓어지기도 합니다. 내부 화면의 전면 카메라는 평소에 화면 아래에 숨어 있고, 사용 중일 때만 한 조각을 차지하며 화면이 자리를 비킵니다.

경고, 메뉴, sheet, 분할은 스스로 적응합니다. 사용자 지정 캔버스, QR 코드, 얼굴, 반드시 이어져야 하는 짧은 문장, 드래그 중인 물체만 이 사각형을 읽으면 됩니다. 가려진 요소만 옮기고, 관련된 컨트롤도 함께 움직입니다. 화면 반대편으로 보내지 마십시오. 스크롤되는 글과 목록은 통째로 옮기지 않습니다.

지금 효력이 있는 것만 거른 다음, 사용자 지정 콘텐츠를 놓습니다

SwiftUI의 reservedRegions에는 layoutDirectionBehavior:를 더할 수 있고, 기본값은 .mirrors입니다. 자신이 직접 미러링했을 때만 .fixed를 넘깁니다. 그렇지 않으면 두 번 미러링됩니다. 카메라의 물리적 위치는 언어에 따라 뒤집히지 않으며, SwiftUI는 기본적으로 기하를 먼저 미러링해 줍니다. onGeometryChange의 proxy도 같은 조회를 할 수 있습니다.

GeometryReader { proxy in
    let folds = proxy.reservedRegions(kind: .division)
        .filter(\.isActive)
    let cameras = proxy.reservedRegions(kind: .occlusion)
        .filter(\.isActive)
    CanvasPage(avoiding: folds + cameras)
}
override func layoutSubviews() {
    super.layoutSubviews()
    let folds = reservedRegions(kind: .division)
        .filter(\.isActive)
    let cameras = reservedRegions(kind: .occlusion)
        .filter(\.isActive)
    placeControls(avoiding: folds + cameras)
}

짝수 열 같은 구조 결정은, 기기가 평평하게 놓여 분할 영역이 아직 활성화되지 않았을 때도 그것을 볼 수 있어야 합니다. 그때만 .includeInactive를 넘기고, 컨트롤을 당장 옮길지는 여전히 isActive로 결정합니다.

let divisions = proxy.reservedRegions(
    kind: .division,
    options: .includeInactive)
let evenColumns = !divisions.isEmpty
let divisions = reservedRegions(
    kind: .division,
    options: .includeInactive)
let evenColumns = !divisions.isEmpty

API

  • reservedRegions(kind:options:)(iOS 27.1). SwiftUI에서는 GeometryProxy 위에 있습니다. UIKit: view.reservedRegions(kind:options:), 반환은 [UIView.ReservedRegion]입니다. 언제 쓰는가: 시스템 컨테이너가 대신 옮겨 주지 않는 사용자 지정 콘텐츠. 언제 쓰지 않는가: 보통 목록, 분할, sheet, 경고. 결과를 안전 영역 inset으로 취급하지 않습니다.

  • ReservedRegion(iOS 27.1). id, kind, frame, margins, isActive. 종류는 .division과 .occlusion입니다. 언제 쓰는가: frame으로 위치를 놓을 때. frame은 이미 여백을 포함합니다. 언제 쓰지 않는가: 활성화되지 않은 division으로 버튼을 밀어 내지 않습니다. 평평하게 놓였을 때 그것은 탭 위치를 바꾸면 안 됩니다.

  • .includeInactive(iOS 27.1). ReservedRegion.QueryOptions. 언제 쓰는가: 「접힘선이 존재하기만 하면 그리드는 짝수 열」처럼, 지금 반으로 접혔는지와 무관한 결정. 언제 쓰지 않는가: 비활성 영역을 찾았다고 버튼을 미리 옮기지 않습니다.

  • layoutDirectionBehavior. .mirrors 또는 .fixed. 언제 쓰는가: 기본값을 유지해서, 자동 레이아웃되는 자식 뷰와 영역이 같은 방향을 쓰게 할 때. 언제 쓰지 않는가: 자신이 수동으로 미러링할 때만 .fixed를 넘깁니다.

확인 문제

  1. 완전히 평평하게 펼친 뒤, division은 아직 활성화되어 있나요? 너비는 얼마인가요?

  2. 스크롤할 수 있는 글이라면, 각 문단을 접힘선에서 옮겨야 하나요?

  3. 내부 화면 카메라는 평소에 occlusion을 차지하나요?

이 장에서 아직 하면 안 되는 일. 플레이어와 재생 목록처럼 짝을 이루는 콘텐츠는 다음 장에서 Arrangement에 맡깁니다. 힌지 각도로 접힘선을 피할지 결정하지 마십시오.

출처: ReservedRegion. Preparing · Adapt to reserved regions. Tech Talk 111463.

제 8장 · 두 콘텐츠가 하나의 경험이면, 컨테이너가 자리를 바꾸게 합니다

이 장을 마치면. 한 쌍의 뷰에 split을 쓸지 overlay를 쓸지 판단하고, 탐색은 이 컨테이너 밖에 둘 수 있습니다.

깨지는 예

Arrangement로 TabView나 NavigationSplitView를 바꿉니다. 또는 Arrangement를 List, ScrollView, 분할의 한 열 안에 넣습니다. 그러면 콘텐츠의 절반이 스크롤되어 나오지 않거나, 누를 수 없게 됩니다.

이미 두 종류의 스택처럼 보이면, 그쪽으로 대응시킵니다

Arrangement에는 주 뷰와 보조 뷰만 있습니다. 그것은 공간, 가로세로 비, size class, 분할 영역을 보고, Duo인지 아닌지는 보지 않습니다. 탐색 컨테이너는 바깥에 감쌉니다. 애플 자신의 예도 탐색 스택 안에 Arrangement를 하나 두는 것입니다. Arrangement 안에 분할을 다시 넣지 마십시오.

  • split. 원래의 HStack / VStack, 또는 어느 쪽도 다른 쪽을 가리면 안 되는 주·보조 관계에 대응합니다. 높이보다 넓으면 좌우로 나누고, 너비보다 높으면 위아래로 쌓습니다. 분할 영역이 나타나면 양쪽을 조정해서 접힘선을 피합니다. 한 축으로 제한한 뒤 그 축을 쓸 수 없으면 주 뷰만 남깁니다. .split.axes(.horizontal)는 가로 축에 놓지 못할 때 주 콘텐츠만 보여 줍니다.

  • overlay. 원래의 ZStack에 대응합니다. 활성화된 분할 영역이 없으면 주 뷰가 보조 뷰 위에 덮입니다(닫혔거나 완전히 펼쳐졌을 때). 반으로 접히면 주 뷰는 접힘선의 뒤쪽 또는 아래쪽으로, 보조 뷰는 앞쪽 또는 위쪽으로 갑니다.

플레이어와 목록: split

NavigationStack {
    ArrangementView {
        Player()
    } secondary: {
        Queue()
    }
    .arrangementViewStyle(.split)
}
let arrangement = UIArrangementViewController()
arrangement.setViewController(player, for: .primary)
arrangement.setViewController(queue, for: .secondary)
arrangement.updateArrangement(.split)
navigationController.setViewControllers([arrangement], animated: false)

좌우만 허용하고, 세로일 때는 플레이어만 남깁니다.

.arrangementViewStyle(.split.axes(.horizontal))
arrangement.updateArrangement(.split.axes(.horizontal))

컨트롤이 화면 위에 덮일 때: overlay

ArrangementView {
    TransportControls()
} secondary: {
    Stage()
}
.arrangementViewStyle(.overlay)
arrangement.setViewController(controls, for: .primary)
arrangement.setViewController(stage, for: .secondary)
arrangement.updateArrangement(.overlay)

비율과 계층에는 이런 조절도 있고, 모두 iOS 27.1입니다. splitArrangementLayoutRatio, splitArrangementLayoutSize, splitArrangementFixedLayoutSize, overlayArrangementEdge. 환경 값 splitArrangementAxis와 overlayArrangementZIndex는 자식 뷰가 지금 어느 배열에 있는지 알게 합니다. UIKit은 state(for:)로 zIndex, splitAxis, isHidden을 읽습니다. 주·보조 뷰의 크기 범위는 UISplitArrangement.ViewProperties에 있고, 가리는 변은 UIOverlayArrangement.ViewProperties.edge에 있습니다.

API

  • ArrangementView(iOS 27.1). init은 primary와 secondary를 받습니다. UIKit: UIArrangementViewController, setViewController(_:for:animated:)의 위치는 .primary / .secondary입니다. 언제 쓰는가: 플레이어와 대기열, 편집기와 인스펙터, 캔버스와 속성. 두 조각이 같은 경험일 때. 언제 쓰지 않는가: 앱의 1단계 탐색. 분할, 목록, 스크롤 뷰 안에 넣지 않습니다.

  • .split(iOS 27.1). arrangementViewStyle(.split), .axes(.horizontal) 또는 .vertical. UIKit: updateArrangement(.split.axes(...)). 언제 쓰는가: 양쪽 모두 전용 공간이 필요하고, 어느 쪽도 다른 쪽을 덮지 않을 때. 언제 쓰지 않는가: 컨트롤이 원래 콘텐츠 위에 떠 있어야 할 때. 그것은 overlay입니다.

  • .overlay(iOS 27.1). 분할 영역이 없으면 주 뷰가 위에 있습니다. 반으로 접히면 주 뷰는 뒤쪽 또는 아래쪽, 보조 뷰는 앞쪽 또는 위쪽으로 갑니다. 언제 쓰는가: 전경 / 배경처럼, 평소에 서로 가려도 될 때. 언제 쓰지 않는가: 두 열 메일. 그것은 제 4장의 분할이거나, 여기서의 split입니다.

  • updateArrangement(_:animated:)(iOS 27.1). split / overlay를 바꾸거나, 축만 바꿉니다. 언제 쓰는가: 제품이 한 축을 분명히 잠그거나, 두 스타일 사이를 전환할 때. 언제 쓰지 않는가: 회전할 때마다 frame을 직접 다시 계산한 뒤 이것을 호출하지 않습니다. 기본 스타일은 이미 컨테이너를 따라갑니다.

확인 문제

  1. 원래 ZStack이었고, 반으로 접히면 접힘선 양쪽으로 나눠야 합니다. split인가요, overlay인가요?

  2. Arrangement가 탐색을 감싸야 하나요, 탐색에 감싸여야 하나요?

  3. 이것을 ScrollView 안에 넣으면 어떻게 되나요?

이 장에서 아직 하면 안 되는 일. 힌지 각도를 읽어서 split인지 overlay인지 결정하지 마십시오. 각도는 제 10장의 상호작용 입력입니다.

출처: Preparing의 SwiftUI / UIKit 예. HIG · Arrangement views. 서명은 iOS 27.1 SDK의 ArrangementView와 UIArrangementViewController에 맞춰 확인했습니다.

제 9장 · 열고 닫힘은 레이아웃 변화이지, 앱을 다시 시작하는 일이 아닙니다

이 장을 마치면. 탐색, 스크롤, 선택, 편집, 재생을 레이아웃 위에 남겨 두고, Duo에서 화면 분할과 새 창의 경계를 알 수 있습니다.

깨지는 예

닫혀 있을 때 목록은 세 번째 편지를 선택했습니다. 펼친 뒤 다른 뷰 트리는 첫 번째 편지부터 시작하고, 초안도 비어 있습니다. 또 다른 실패는 외부 화면 전체와 내부 화면 전체만 시험하는 것입니다. 화면을 반쯤 나누면 막대가 겹치고, 글이 잘립니다. 「펼치면 반드시 아주 넓다」고 가정하기도 합니다.

이어지는 너비 하나, 그리고 하드웨어 제한 둘

내부 화면에서 모든 앱은 Split View에 참여하고, 비디오도 앱과 겹칠 수 있습니다. 각 앱의 세로 막대는 자기 바깥쪽에 붙으므로, 자신의 막대는 왼쪽일 수도 있고 오른쪽일 수도 있습니다. 양쪽을 모두 시험합니다.

아주 넓은 레이아웃은 중간 너비로 돌아갈 수 있어야 하고, 다시 compact로 돌아갈 수 있어야 합니다. 상태, 즉 탐색 경로, 스크롤, 선택, 편집기, 재생 진행, 저장하지 않은 폼은, 한 열인지 두 열인지를 결정하는 층보다 위에 둡니다.

Duo는 여러 장면을 지원하는 첫 iPhone입니다. iPad에서 새 창을 여는 앱은 여기서도 엽니다. 새 창은 외부 화면에서 만들 수 없습니다. 요청이 실패하면 현재 창에 남습니다. UIWindowScene.ActivationAction의 구성 클로저는 ActivationConfiguration을 반환해야 하며, 요청 객체를 고치면 안 됩니다. 플랫폼이 여러 창을 지원하지 않으면 자신이 제공한 alternate로 가고, 외부 화면에서 누르면 반드시 두 번째 창이 열린다고 가정하지 마십시오. UIRequiresFullScreen은 여전히 유효하지만, 기기를 접고 펴면 화면 크기는 그래도 변합니다.

선택 항목은 레이아웃 밖에서 살아 있습니다

struct Mailbox: View {
    @State private var path: [Message.ID] = []
    @State private var selection: Message.ID?
    @State private var draft = ""

    var body: some View {
        NavigationSplitView {
            MessageList(selection: $selection)
        } detail: {
            Editor(text: $draft, messageID: selection)
        }
    }
}
let action = UIWindowScene.ActivationAction(
    title: "新窗口",
    image: UIImage(systemName: "plus.rectangle")
) { _ in
    let activity = NSUserActivity(activityType: "mailbox.message")
    return UIWindowScene.ActivationConfiguration(userActivity: activity)
}

스크롤 위치는 시스템 스크롤 뷰 자신의 저장을 쓰거나, 모델 안에 둔 식별자를 씁니다. 닫을 때 scrollTo로 맨 위에 돌아가지 마십시오. 재생은 같은 플레이어 객체를 쓰고, 크기가 바뀔 때 replace로 갈아 끼우지 마십시오.

확인할 사실

  • 상태 끌어올리기. 선택, 경로, 초안, 플레이어는 적응형 컨테이너 밖에 둡니다. 언제 쓰는가: compact와 regular 사이를 오가는 모든 페이지. 언제 쓰지 않는가: 외부 화면과 내부 화면을 위해 상태 객체를 각각 만들지 않습니다.

  • Split View. 막대는 그 앱의 바깥쪽에 있습니다. 왼쪽 절반의 막대는 왼쪽, 오른쪽 절반의 막대는 오른쪽입니다. 언제 쓰는가: Device Hub에서 자신을 왼쪽과 오른쪽에 각각 놓아 봅니다. 언제 쓰지 않는가: 세로 막대가 영원히 trailing에 있다고 가정하지 않습니다.

  • UIWindowScene.ActivationAction(iOS 15). 클로저는 UIWindowScene.ActivationConfiguration(userActivity:)를 반환합니다. 플랫폼이 여러 창을 지원하지 않으면 alternate를 씁니다. 언제 쓰는가: iPad에서 이미 여러 창을 지원하고, Duo 내부 화면에서도 같게 하고 싶을 때. 언제 쓰지 않는가: 외부 화면에서 새 장면을 억지로 요청하면서 실패를 처리하지 않는 일. 새 창은 외부 화면에서 만들 수 없습니다.

  • UIRequiresFullScreen. 전체 화면 요구는 여전히 지켜집니다. 언제 쓰는가: 원래부터 화면을 독점해야 할 때, 기존 설정을 유지합니다. 언제 쓰지 않는가: 이것이 접고 펴서 생기는 크기 변화를 막을 수 있다고 생각하지 않습니다. 레이아웃은 그래도 따라가야 합니다.

확인 문제

  1. 내부 화면을 펼친 뒤, 초안은 어느 상태에서 복원되어야 하나요?

  2. 앱이 화면 분할의 오른쪽에 있을 때, 세로 막대는 어느 쪽에 있을 가능성이 더 큰가요?

  3. 새 창을 외부 화면에서 만들 수 있나요?

이 장에서 아직 하면 안 되는 일. 힌지 각도로 탐색을 초기화하지 마십시오. 열고 닫힘은 이미 크기와 예약 영역으로 표현됩니다. 각도는 진짜 물리적 상호작용에만 남겨 둡니다.

출처: 화면을 넘나드는 연속성과 분할의 바깥쪽 막대에 대한 HIG 설명. 여러 크기를 시험하라는 Preparing의 요구. Tech Talk 111464.

제 10장 · 각도는 효과를 움직이고, 막대와 열 개수는 움직이지 않습니다

이 장을 마치면. 힌지가 있을 때 각도를 음높이, 카메라 구도, 또는 게임 플레이로 바꾸고, 힌지가 없거나 상호작용이 계층을 떠날 때는 안전한 값으로 돌아갈 수 있습니다.

깨지는 예

if hinge.angle > someRadians { showSidebar = true }

평평하게 놓기, 화면 분할, 보통 iPhone은 기대한 대로 각도를 주지 않습니다. 사이드바가 나타나야 하는지는 제 3장과 제 4장이 이미 공간으로 답했습니다.

레이아웃은 공간을 보고, 상호작용만 힌지를 봅니다

각도에 맞는 것은 이것입니다. 물리적 입력, 게임의 장치, 악기, 카메라 자세, 책상 위 상호작용, 또는 열고 닫힘에 분명히 묶인 효과. 맞지 않는 것은 사이드바, 열 수, 그리드 개수, 탐색을 접을지입니다.

SwiftUI에서 DeviceHingeContext.hinge가 nil이면 이 기기에 힌지가 없다는 뜻이지, 「닫혀 있다」는 뜻이 아닙니다. 닫힘은 .closed입니다. angle은 Angle입니다. status는 .closed, .partiallyOpen, .fullyOpen입니다.

UIKit의 nil은 뜻이 다릅니다. 상호작용이 힌지 업데이트를 제공할 수 있는 계층을 떠나면 update.hinge가 nil입니다. 각도는 라디안입니다. UIHinge.Status에는 .unknown이 하나 더 있습니다. 업데이트 빈도와 정밀도는 시스템이 정하므로, 어느 한 프레임 속도에 기대지 마십시오. 열림, 반열림, 완전 열림만 중요하면 status를 읽습니다.

반쯤 열렸을 때 각도를 음높이로 바꾸고, 그 밖에는 0으로 되돌립니다

.onHingeChange { _, context in
    if let hinge = context.hinge, hinge.status == .partiallyOpen {
        pitch = bend(angle: hinge.angle)
    } else {
        pitch = 0
    }
}
let interaction = UIHingeInteraction { [weak self] _, update in
    guard let self else { return }
    guard let hinge = update.hinge, hinge.status == .partiallyOpen else {
        self.pitch = 0
        return
    }
    self.pitch = self.bend(radians: hinge.angle)
}
view.addInteraction(interaction)

onHingeChange(isEnabled:_:)의 isEnabled 기본값은 true입니다. UIKit은 interaction.isEnabled를 씁니다. 꺼 둔 동안에는 콜백하지 않고, 놓친 업데이트를 줄 세우지도 않습니다. 다시 켜면, 현재 계층에 아직 힌지가 있으면 현재 상태를 바로 한 번 더 넘깁니다. 이 클로저는 이스케이프하므로, [weak self]로 순환 참조를 피합니다.

API

  • onHingeChange(iOS 27.1). (isEnabled:_:). 인자는 이전과 새로운 DeviceHingeContext 둘입니다. 언제 쓰는가: 효과가 힌지를 따라 연속으로 변해야 할 때. 언제 쓰지 않는가: 접힘선 피하기, 분할, 그리드. 그것은 예약 영역과 Arrangement를 씁니다.

  • DeviceHinge(iOS 27.1). status와 angle: Angle. 맥락의 hinge는 옵셔널입니다. 언제 쓰는가: nil이면 효과를 0으로 되돌리고, 충돌하지 않습니다. 언제 쓰지 않는가: nil을 「닫혀 있다」로 읽지 않습니다. 닫힘은 .closed입니다.

  • UIHingeInteraction(iOS 27.1). init { interaction, update in }. update.hinge는 옵셔널 UIHinge이고, angle은 라디안이며, 상태에는 .unknown이 있습니다. 언제 쓰는가: 힌지를 관찰할 뷰에 더할 때. 공식 예는 viewDidLoad에서 addInteraction합니다. 언제 쓰지 않는가: 여기서 nil은 상호작용이 업데이트를 제공하는 계층을 떠났다는 뜻이지, 반드시 「이 휴대폰에 힌지가 없다」는 뜻은 아닙니다.

  • 업데이트 빈도. 헤더에는 이렇게 적혀 있습니다. 속도와 세분 단위는 시스템 상태에 따라 변합니다. 언제 쓰는가: 각도를 연속 입력으로 다루고, 프레임 누락을 허용할 때. 언제 쓰지 않는가: 「매초 반드시 N번」으로 보정이나 레이아웃 애니메이션을 만들지 않습니다.

확인 문제

  1. 사이드바가 나타나야 하는지를 hinge.angle로 볼 수 있나요?

  2. SwiftUI에서 hinge == nil은 무엇을 뜻하나요? UIKit의 nil은요?

  3. 반쯤 열렸는지만 알고 싶다면, 각도를 읽나요, status를 읽나요?

이 장에서 아직 하면 안 되는 일. 외부 화면을 자신이 배치하는 두 번째 창으로 취급하지 마십시오. 그것은 다음 장이며, 촬영 중에만 성립합니다.

제 11장 · 외부 화면의 추가 콘텐츠는 촬영 중에만 시스템이 가져와 표시합니다

이 장을 마치면. 주 장면이 두 화면 사이를 어떻게 이동하는지, 그리고 언제 외부 화면에 보충 콘텐츠를 둘 수 있는지 설명할 수 있습니다.

깨지는 예

펼쳤을 때 화면을 UIWindow 두 개로 나눕니다. 하나는 내부 화면을 그리고, 하나는 외부 화면을 그립니다. 시스템은 자신의 두 번째 캔버스를 닫힌 그 면에 남겨 두지 않습니다. 시뮬레이터에는 카메라도 없으므로, 이런 코드는 Device Hub에서 검증할 수 없습니다.

주 경험은 유일한 주 장면을 따라갑니다

접고 펼 때 주 장면이 스스로 다른 면으로 바뀝니다. 모든 중요한 버튼은 주 화면에 남겨 둡니다. 외부 화면의 보충 콘텐츠는 상호작용할 수 있지만, 적어야 합니다.

지원되는 유일한 방식은 카메라 촬영 액세서리입니다. 기기가 펼쳐져 있고, 앱이 전면에 있으며 내부 화면이 전체 화면이고, 진행 중인 촬영 세션이 한 번 있을 때, 시스템이 그것을 외부 화면에 표시할 수 있습니다. 언제, 어디에 둘지는 시스템이 정하고, 언제든 거두어 갈 수 있습니다. isEnabled로 끄고, 등록을 해제해서 켜고 끄지 마십시오. 상태는 같은 model을 탑니다.

시뮬레이터에는 카메라가 없으므로 이 절은 실기기가 필요합니다. 카메라가 향하는 쪽은 접고 펴거나 회전한 뒤 반대로 바뀔 수 있습니다. 카메라를 고를 때는 「그것이 어느 면을 향하는가」를 기준으로 합니다. Choosing a camera by the direction it faces를 보십시오.

ExternalNonInteractiveAccessory는 외부 디스플레이와 AirPlay이지, Duo의 외부 화면이 아닙니다.

프롬프터: 주 화면은 그대로 찍을 수 있습니다

CameraCaptureAccessory는 SDK에서 macOS, tvOS, watchOS, visionOS, Mac Catalyst에 쓸 수 없다고 표시되어 있습니다. sceneAccessory와 registerSceneAccessory(_:)는 iOS 27.0부터 있습니다. cameraCapture(sceneConfiguration:)와 세션 역할 .windowCameraCaptureAccessory는 iOS 27.1입니다. 맥락을 장면 델리게이트에 넘겨야 하면 userInfo가 있는 팩토리 메서드를 쓰고, UISceneConnectionOptions.sceneAccessoryUserInfo에서 도로 꺼냅니다.

CameraView(model: model)
    .sceneAccessory {
        CameraCaptureAccessory(isEnabled: $model.prompterEnabled) {
            Prompter(model: model)
        }
        .onAvailabilityChange { model.prompterAvailable = $0 }
    }
let configuration = UISceneConfiguration(
    name: "Capture Accessory",
    sessionRole: .windowCameraCaptureAccessory)
let accessory = UISceneAccessory.cameraCapture(
    sceneConfiguration: configuration)
registration = registerSceneAccessory(accessory)
// 配件场景的 delegate 画提词器。
// 快门仍在主场景里。

API

  • CameraCaptureAccessory(iOS 27.1). init(isEnabled:content:). sceneAccessory 안에 겁니다. onAvailabilityChange는 시스템이 아직 그것을 보여 주려 하는지 알립니다. 언제 쓰는가: 프롬프트, 카운트다운, 찍히는 사람이 화면을 보게 할 때. 등록은 촬영 UI와 같은 뷰에 둡니다. 언제 쓰지 않는가: 주 경로의 셔터, 초점, 설정. 외부 화면은 언제든 사라질 수 있습니다.

  • registerSceneAccessory(iOS 27.0). UIKit: UISceneAccessory.cameraCapture(sceneConfiguration:), 역할 .windowCameraCaptureAccessory. 반환된 registration으로 unregisterSceneAccessory를 합니다. 언제 쓰는가: 촬영 세션이 있는 동안의 보충 장면. 언제 쓰지 않는가: 잠시 숨기려고 unregister하지 않습니다. 사용 상태를 씁니다.

  • ExternalNonInteractiveAccessory. 연결된 디스플레이 / AirPlay. 언제 쓰는가: 원래 외부 대형 화면을 지원해야 하고, 콘텐츠에 상호작용이 필요 없을 때. 언제 쓰지 않는가: Duo를 닫았을 때의 그 외부 화면.

  • 시뮬레이터. Device Hub는 열고 닫고, 회전하고, 접을 수 있습니다. 카메라는 없습니다. 대부분의 앱 확장도 Duo 시뮬레이터에서 실행되지 않습니다. 언제 쓰는가: 레이아웃, 막대, sheet, 화면 분할. 언제 쓰지 않는가: 시뮬레이터에서 촬영 액세서리를 이미 검증했다고 선언하지 않습니다.

확인 문제

  1. 촬영하지 않을 때, 대시보드 한 장을 외부 화면에 고정할 수 있나요?

  2. 프롬프터를 쓸 수 없을 때, 셔터는 어디에 있어야 하나요?

  3. 왜 이 부분은 시뮬레이터만으로 끝낼 수 없나요?

여기까지입니다. 제품에 카메라가 없으면 이 장은 코드를 쓰지 않아도 됩니다. 앞의 제 0–9장이 설계에서 적응형 레이아웃까지의 주 경로를 이미 덮습니다.

출처: Preparing · Improve your app’s camera handling. Tech Talk 111465.

부록 · 대조, 위험 신호, 그리고 정답

앞의 열두 장은 순서대로 판단을 쌓았습니다. 여기서는 API 세 층, 나타나서는 안 되는 쓰기, 확인 문제의 정답을 한데 둡니다. 한 장을 끝낸 뒤 돌아와서 맞추기 쉽게 합니다.

세 층, 건너뛰지 않습니다

층 언제 쓰는가 SwiftUI UIKit
1 · 어떤 크기든 제 3–6장. 모든 Apple 창이 이득을 본다 NavigationSplitView, TabView, GridItem(.adaptive), ViewThatFits, AnyLayout, size class, onGeometryChange, 시스템 .toolbar UISplitViewController, UINavigationController, UITabBarController, 컨테이너 너비, trait, layoutSubviews, viewWillTransition
2 · 접힘선과 세로 막대를 알 때 제 5–8장에서, 시스템 컨테이너가 부족할 때 reservedRegions, ArrangementView, toolbarVerticalBehavior, axisBehavior, visibilityPriority, presentationPlacement, toolbarVerticalEdge reservedRegions(kind:options:), UIArrangementViewController, preferredVerticalBarBehavior, axisBehavior, verticalBarEdge, preferredPlacement
3 · 하드웨어 자체만 제 10–11장, 제품이 정말로 필요할 때 onHingeChange, CameraCaptureAccessory, sceneAccessory UIHingeInteraction, UISceneAccessory.cameraCapture, 역할 .windowCameraCaptureAccessory

세로 막대의 항목 규칙은 제 5장에 있습니다. 27.1 SDK가 시스템 막대를 세우기 때문이지, 접힘선을 조회해야 하기 때문이 아닙니다. 접힘선 조회는 제 7장에서야 시작합니다.

가용성, 섞어 기억하지 않습니다

시스템 이 단계에서야 있는 것
iOS 26 ConcentricRectangle, backgroundExtensionEffect(), UIBackgroundExtensionView, UICornerConfiguration
iOS 27.0 visibilityPriority, ToolbarOverflowMenu, topBarPinnedTrailing, presentationPlacement, defaultTabBarPlacement, isTabViewSidebarAvailable, sceneAccessory / registerSceneAccessory
iOS 27.1 세로 막대 동작과 변, axisBehavior, 압축 동작, 예약 영역, Arrangement, 힌지, CameraCaptureAccessory

UIKit의 leadingItemGroups, pinnedTrailingGroup, additionalOverflowItems는 iOS 16부터 쓸 수 있습니다. Duo에서는 이들이 세로 막대 안의 순서와 넘침을 맡지만, 프로퍼티 자체가 27.1에 처음 나타난 것은 아닙니다.

SwiftUI의 가시성 우선순위는 .automatic / .low / .high입니다. UIKit의 대응 값은 .standard / .low / .high입니다. 압축 동작도 이름이 하나 다릅니다. SwiftUI는 .prefersToolbarItems, UIKit은 .prefersBarItems입니다.

파일을 열면 먼저 이것들을 검색합니다

일반 레이아웃에 쓰였다면 고쳐야 합니다.

  • UIScreen.main.bounds, UIScreen.main.scale, 저장해 둔 UIScreen

  • UIWindow(frame: UIScreen.main.bounds)

  • userInterfaceIdiom == .pad 또는 .phone

  • UIDevice.current.orientation, statusBarOrientation, interfaceOrientation, effectiveGeometry.interfaceOrientation을 포함합니다

  • horizontalSizeClass == .regular이면 「iPad 전용」 페이지로 들어감

  • bounds.height == 844 또는 알려진 기종 높이 표 하나

  • safeAreaInsets.left * 2

  • if isDuo, if isFolded, 힌지 각도로 사이드바나 열 수를 결정함

  • 고정된 큰 frame, 고정된 사이드바 너비, 고정된 그리드 열 수

  • 직접 그린 상단 막대, 또는 따로 만든 UIToolbar / UINavigationBar / UITabBar

  • 제목만 있고 아이콘이 없는 도구 막대 버튼. 직접 그린 「…」 메뉴

  • 중요한 콘텐츠가 접힘선 한가운데를 누름. compact와 펼침이 상태를 각각 저장함

  • .fill인 주 비주얼에 초점이 없고, 비율에 따라 fill / fit을 고르지도 않음

  • 문서에 적힌 예외 밖에서 toolbarVerticalBehavior(.disabled)를 쓰거나, 페이지 상태에 따라 왔다 갔다 함

Device Hub에서 최소한 보아 둔 자세

자세 기대
닫힌 세로 외부 화면, compact 너비 + regular 높이, 세로 막대
닫힌 가로 양쪽 모두 compact, 세로 막대
펼친 세로 내부 화면 regular × regular, 가로 막대. 위아래 막대를 유지하는 유일한 흔한 자세
펼친 가로 regular × regular, 세로 막대
반으로 접힘 같은 계층. 정밀한 콘텐츠는 접힘선 위에 걸치지 않는다. 스크롤 콘텐츠는 페이지 전체를 우회하지 않는다
내부 화면 분할, 자신이 왼쪽, 오른쪽 막대는 자기 바깥쪽에 붙는다. 상태는 그대로 있다

모든 sheet와 popover는 이 자세들에서 한 번씩 엽니다. 스토어 스크린샷은 App Store Connect의 Screenshot specifications를 기준으로 합니다. 흔히 정리하는 크기는 외부 화면 1398×2034, 내부 화면 2007×2853이고, 가로에서는 양쪽을 바꿉니다. 이 숫자를 레이아웃에 쓰지 마십시오. 자료에 「2027년 4월부터 제출에 Duo 스크린샷이 필요하다」고 적혀 있으면, 정확한 날짜는 그때의 애플 공지를 따릅니다.

확인 문제 정답

제 0장. 내부 화면 세로의 시스템 막대는 위아래에 있습니다. 여섯 가지 자세도 여전히 화면 한 벌입니다. 펼친 뒤에도 그것은 iPhone 앱이며, 내부 화면이 regular × regular가 될 뿐입니다.

제 1장. 900pt 너비의 설정 폼은 문장을 끝까지 늘립니다. 레이블과 스위치가 빈칸에 의해 갈라집니다. 너비를 제한하고, 남는 공간은 다음 계층에 남겨야 합니다. 그라데이션은 한가운데에 남아도 되고, 결제 버튼은 안 됩니다. 목록의 전체 선택은 목록을 따라가고, 가장 오른쪽 시스템 막대를 따라가지 않습니다.

제 2장. 배포 타깃은 iOS 18로 남아 있어도 됩니다. 27.1 SDK로 컴파일한 뒤에 세로 막대가 나타납니다. 사방이 비어 있으면 컴파일 SDK를 바꾸고, 기종을 감지해서 frame을 늘리지 않습니다. 새 심볼은 오래된 시스템에 없으므로 #available 안에 넣어야 합니다.

제 3장. regular 너비는 Duo 내부 화면에서 성립하므로, 더 이상 iPad와 같지 않습니다. 내부 화면은 방향 선언을 따르지 않으며, 넓은지 높은지는 컨테이너의 너비와 높이를 비교합니다. 좌우 inset은 다를 수 있으므로, left * 2는 반대쪽을 잘못 계산합니다.

제 4장. 선택 항목은 분할 밖에 있는 같은 상태 한 벌에 둡니다. 열 수는 컨테이너 너비를 최소 항목 너비로 나눈 값이며, 여기서는 2가 됩니다. ViewThatFits가 답하는 것은 어느 배열이 들어가는가입니다.

제 5장. 제목만 있는 버튼은 세로 막대에 들어가지 않습니다. 분할에서는 세부 열의 막대만 세로가 됩니다. 탐색형 페이지는 기본적으로 도구 항목을 먼저 오버플로로 거두고, 탭 막대를 지킵니다. 따로 만든 UIToolbar는 탐색 컨테이너가 제공하는 막대가 아니므로, 시스템은 그것을 세우지 않습니다.

제 6장. 내부 화면에서 trailing sheet의 도구 막대는 세로입니다. 좌우 안전 영역은 대칭이 아니므로 거울처럼 복사할 수 없습니다. 직접 그린 전체 화면 가림은 시스템 sheet처럼 접힘선을 피하지 않습니다.

제 7장. 완전히 평평하게 펼치면 division은 활성화되지 않고, 너비는 0입니다. 스크롤할 수 있는 글은 문단마다 옮기지 않습니다. 내부 화면 카메라는 평소에 occlusion을 차지하지 않고, 사용 중일 때만 차지합니다.

제 8장. ZStack은 overlay에 대응합니다. Arrangement는 탐색에 감싸이며, 그 반대가 아닙니다. ScrollView 안에 넣으면 그 안의 한 조각에 손이 닿지 않게 됩니다.

제 9장. 초안은 같은 상태에서 복원되며, 다른 뷰 트리에서 복원되지 않습니다. 화면 분할 오른쪽의 세로 막대는 오른쪽, 즉 그 앱의 바깥쪽에 있습니다. 새 창은 외부 화면에서 만들 수 없습니다.

제 10장. 사이드바는 힌지 각도를 보지 않습니다. SwiftUI의 nil은 이 기기에 힌지가 없다는 뜻입니다. UIKit의 nil은 상호작용이 업데이트를 제공할 수 있는 계층을 떠났다는 뜻입니다. 반쯤 열렸는지만 알고 싶다면 status를 읽습니다.

제 11장. 촬영하지 않을 때는 대시보드를 외부 화면에 고정할 수 없습니다. 셔터는 주 화면에 남깁니다. 시뮬레이터에는 카메라가 없으므로, 촬영 액세서리는 실기기가 필요합니다.

공식 입구

모범 사례 · 하단 입구는 그대로 두고, 너비는 콘텐츠에 맡긴다

이 글을 보고 나면. 이 앱을 앞의 규칙과 대조해서, 무엇이 이미 애플의 방식으로 내려앉았는지, 무엇이 제품의 선택인지, 무엇이 아직 검증했다고 볼 수 없는지 가를 수 있습니다.

이 앱에는 Duo 버전이 없습니다. 배포 타깃은 여전히 iOS 18이고, 27.1 SDK로 컴파일해야 화면을 가득 채우고 시스템 막대가 세로가 될 수 있습니다. 접고 펴도 라우트를 바꾸지 않고, 페이지 모델을 바꾸지 않습니다. 늘어난 regular 너비는 「오늘」과 「호흡」 안의 좌우 열에 쓰고, 1단계 입구 세 개를 사이드바로 바꾸지 않습니다. 힌지 각도, 카메라 외부 화면, 여러 창에는 제품상의 이유가 없으므로 쓰지 않습니다.

대조

튜토리얼의 판단 이 앱이 하는 방식
제 0–2장: 화면 한 벌, 스위치는 SDK 기기 이름으로 분기하지 않습니다. 새 API는 #available로 감싸고, 오래된 시스템에는 같은 관계의 HStack / VStack을 남깁니다
제 3–4장: 컨테이너 너비로 다음 계층을 드러냄 compact 셸은 더 넓어져도 한 열입니다. regular는 800pt에서도 한 열이고, 801pt부터 좌우로 나란히 둡니다. 최대 주 열 480pt와 읽기 좋은 세부 720pt(합계 1201pt)가 들어갈 때 더 성긴 단계로 들어갑니다. 1024pt에서도 나란히 있는 것이며, 문장을 끝까지 늘리는 것이 아닙니다
제 5장: 시스템 막대이되, 사이드바는 필수가 아님 .sidebarAdaptable에 defaultTabBarPlacement(.tabBar)를 더하고, 탭 세 개는 아래에 둡니다
제 6장: Sheet는 위치를 선언함. 직접 그린 가림은 접힘을 피하지 않음 시스템 sheet는 regular에서 의도에 따라 trailing 또는 center로 갑니다. compact 결제 층은 여전히 루트의 가림이며, 접힘선을 자동으로 피하지 않습니다
제 7–8장: 짝을 이루는 두 콘텐츠만 옮김 호흡, 결제 가로 막대, 같은 너비의 주·보조 열은 ArrangementView의 split을 씁니다. 스크롤되는 홈과 세부는 접힘선 때문에 길을 바꾸지 않습니다
제 9–11장: 상태는 이어짐. 하드웨어 API는 선택 선택과 라우트는 레이아웃 밖에 있습니다. onHingeChange, 촬영 액세서리, 여러 창은 만들지 않습니다

루트 탐색은 입구 세 개를 유지합니다

내부 화면은 사이드바가 될 수 있지만, 이 제품은 그 선택을 하지 않습니다. 「오늘」, 추세, 호흡은 여전히 하단 탭입니다. 설정은 「오늘」에서 밀어 넣습니다. iPad 경로는 defaultAdaptableTabBarPlacement(.tabBar)를 쓰고, iOS 27에서는 defaultTabBarPlacement(.tabBar)를 다시 분명히 합니다. 내부 화면을 펼친 뒤 늘어난 공간은 페이지 안에 나타납니다. 오늘은 왼쪽 카드와 오른쪽 세부, 호흡은 왼쪽 모드와 오른쪽 연습입니다.

나란히 두려면 두 가지가 동시에 맞아야 합니다. 이 페이지의 셸이 regular이고, 현재 가로 size class도 regular입니다. 그러면 좁은 그 트리가 공유 선택 항목을 빼앗지 않습니다. 셸이 compact이면, 컨테이너가 아주 넓어도 단계는 여전히 한 열입니다.

TabView(selection: adaptiveSectionSelection) {
    // 三个 Tab
}
.tabViewStyle(.sidebarAdaptable)
.defaultAdaptableTabBarPlacement(.tabBar)
// iOS 27:content.defaultTabBarPlacement(.tabBar)
adaptiveShellRole == .regular && horizontalSizeClass == .regular

Arrangement는 이미 있는 관계 하나만 설명합니다

탐색은 밖에 둡니다. 낮은 버전의 레이아웃과 27.1의 Arrangement는 같은 관계이지, 다른 페이지 한 벌이 아닙니다.

장면 원래의 관계 27.1
호흡 연습 주 비주얼이 위, 컨트롤이 아래 .split. 높으면 위아래, 넓으면 좌우이며, 접힘선을 피합니다. 축을 잠그지 않고, 힌지 각도도 보지 않습니다
결제 페이지 가로 주 비주얼과 구매가 나란히 .split. 오래된 시스템은 여전히 HStack입니다
같은 너비의 주·보조 열 두 열 모두 전용 공간이 필요 .split.axes(.horizontal), 비율 0.5. 배경은 접힘선을 넘을 수 있고, 누를 수 있는 콘텐츠는 Arrangement 안에 있습니다

시스템이 해 주는 것, 그리고 아직 끝났다고 볼 수 없는 것

안전 영역은 네 변으로 읽습니다. 스크롤 목록과 세부 안의 그림은 반접힘 때문에 자리를 옮기지 않습니다. 시스템 sheet는 compact에서 .automatic으로 가고, regular에서는 의도에 따라 trailing 또는 center를 고릅니다.

분명히 하지 않는 것. 힌지 각도로 호흡이나 사이드바를 놓지 않습니다. 카메라가 없으므로 촬영 액세서리를 등록하지 않습니다. 외부 화면에서 새 창을 만들지 않습니다. 여섯 가지 자세를 위해 페이지 모델을 복제하지 않습니다. 제품이 정말로 열고 닫힘을 입력으로 삼거나, 촬영 화면이 생길 때에만 다시 생각합니다.

아직 끝났다고 볼 수 없는 것. 여섯 가지 자세는 Device Hub에서 손으로 보아야 합니다. 공개된 시뮬레이터 명령으로는 열고 닫거나 반으로 접을 수 없습니다. compact 결제 가림은 시스템 sheet가 아닙니다. 닫기 버튼이 하나뿐인 설명 sheet에서 세로 막대를 끌지는 아직 정해지지 않았습니다. 공유 요소 전환에 쓰는 고정 상단 막대는 아직 확대·축소 층 밖에 남아 있습니다. 컴파일이 통과했다고 접힘선과 세로 막대를 본 것은 아닙니다.

새 페이지를 어떻게 붙이는가. 먼저 기존 탭이나 탐색 스택에 넣고, 상태는 밖에 남겨 둡니다. 그다음 컨테이너 단계로 한 열인지 나란히인지를 정하고, 긴 글은 대략 720pt에서 멈춥니다. 두 콘텐츠가 원래 자리를 바꿔야 할 때만 Arrangement를 쓰고, #available(iOS 27.1, *)로 오래된 레이아웃을 남깁니다. 먼저 시스템 toolbar, size class, 읽기 좋은 너비를 씁니다. 그다음에 예약 영역과 Arrangement를 씁니다. 힌지와 보조 화면은 제품이 필요로 할 때까지 계속 쓰지 않습니다.

이 앱으로 스스로 시험합니다

  1. 내부 화면이 넓어진 뒤, 탭 세 개는 사이드바로 올라가야 하나요, 아래에 남아야 하나요?

  2. regular 컨테이너가 800pt뿐일 때, 「오늘」은 한 열인가요, 나란히인가요?

  3. 호흡 연습의 구체와 컨트롤은 왜 onHingeChange가 아니라 .split을 쓰나요?

  4. compact 결제 가림이 반으로 접혔을 때 접힘선 위에 있으면, 「시스템 sheet는 피한다」고 말할 수 있나요?

정답. 탭 세 개는 아래에 남습니다. 800pt는 여전히 한 열이고, 801pt에서야 나란히입니다. 구체와 컨트롤은 같은 연습의 위아래 관계이므로 split에 맡기고, 각도는 레이아웃을 결정하지 않습니다. 결제 가림은 시스템 sheet가 아니므로 접힘을 자동으로 피하지 않습니다.

더 읽기

Stress HRV에서 이 아이디어를 경험하기

App Store에서 다운로드

이 노트는 개인 웰니스를 위한 제품 생각이며 의료 조언이 아닙니다.