2026년 9월 30일 수요일

아이폰 듀오에 대응하기 위한 코드들 #1

 iPhone Duo는 커버 디스플레이(5.4인치)와 펼쳤을 때의 메인 폴더블 디스플레이(7.6인치)를 제공하는 듀얼/폴더블 스크린 디바이스입니다.   


SwiftUI에서는 화면의 무분별한 하드코딩 대신 Size Class, GeometryReader, Hinge Angle API, Vertical Toolbar, ArrangementView 등 확장된 레이아웃 프레임워크를 활용해 디스플레이 상태에 완벽하게 대응합니다.   


iPhone Duo 대응을 위한 핵심 레이아웃 개념

Size Class Adaptive System: 접었을 때(horizontalSizeClass == .compact), 펼쳤을 때(horizontalSizeClass == .regular) 레이아웃 분기.


Hinge Angle Sensing: 힌지의 접힌 각도(0°~180°)에 따라 스탠드 모드, 테이블톱 모드, 완전히 펼친 모드로 앱의 UI 전환.   



Safe Area & Reserved Regions: 힌지 영역이나 사이드 상태바(Status Bar) 영역을 회피하는 안전 영역 처리.


Vertical Toolbar & Controls: iOS에서 수직 배치 모디파이어를 제공하여 힌지 측면/우측에 툴바 배치.


Step-by-Step 실습 데모 코드 10선

1. Size Class에 따른 화면 전환 (Basic Responsive View)

커버 스크린과 내장 스크린의 기본 레이아웃 대응 규칙을 배웁니다.


Swift

import SwiftUI


struct Demo01_SizeClassView: View {

    @Environment(\.horizontalSizeClass) var horizontalSizeClass

    

    var body: some View {

        Group {

            if horizontalSizeClass == .compact {

                // 커버 디스플레이 (단일 컬럼)

                VStack(spacing: 20) {

                    Image(systemName: "iphone")

                        .font(.system(size: 60))

                    Text("Cover Display Mode")

                        .font(.title2)

                        .bold()

                }

            } else {

                // 펼쳐진 메인 디스플레이 (나란히 배치)

                HStack(spacing: 30) {

                    VStack {

                        Image(systemName: "rectangle.split.2x1")

                            .font(.system(size: 60))

                        Text("Left Screen Region")

                    }

                    Divider()

                    VStack {

                        Image(systemName: "sidebar.right")

                            .font(.system(size: 60))

                        Text("Right Screen Region")

                    }

                }

                .padding()

            }

        }

        .animation(.default, value: horizontalSizeClass)

    }

}

2. NavigationSplitView를 활용한 마스터-디테일 패턴

접었을 때는 Stack Navigation으로 동작하고, 펼쳤을 때는 2-Column SplitView로 자동 전환되는 표준 패턴입니다.


Swift

import SwiftUI


struct Item: Identifiable, Hashable {

    let id = UUID()

    let title: String

    let description: String

}


struct Demo02_SplitView: View {

    @State private var items = (1...10).map { Item(title: "Item \($0)", description: "Detail content for item \($0)") }

    @State private var selectedItem: Item?

    

    var body: some View {

        NavigationSplitView {

            List(items, selection: $selectedItem) { item in

                NavigationLink(value: item) {

                    Text(item.title)

                }

            }

            .navigationTitle("Duo Items")

        } detail: {

            if let item = selectedItem {

                VStack(alignment: .leading, spacing: 16) {

                    Text(item.title)

                        .font(.largeTitle)

                        .bold()

                    Text(item.description)

                        .font(.body)

                    Spacer()

                }

                .padding()

            } else {

                Text("Select an item from list")

                    .foregroundColor(.secondary)

            }

        }

    }

}

3. Hinge Angle 감지와 Pose 감지 (Flex/Tabletop Mode)

기기의 접힘 각도(hingeAngle)를 감지하여 90도 안팎으로 접혔을 때 카메라/플레이어 컨트롤러 형태로 변경합니다.


Swift

import SwiftUI


// iPhone Duo 힌지 각도 Environment Key (가상 정의 구조)

struct HingeAngleKey: EnvironmentKey {

    static let defaultValue: Double = 180.0 // 180 = 완전 펼침

}


extension EnvironmentValues {

    var hingeAngle: Double {

        get { self[HingeAngleKey.self] }

        set { self[HingeAngleKey.self] = newValue }

    }

}


struct Demo03_HingePoseView: View {

    @Environment(\.hingeAngle) var hingeAngle

    

    // 70도~110도 사이면 Flex / Tabletop 모드로 진단

    var isTabletopMode: Bool {

        return hingeAngle >= 70 && hingeAngle <= 110

    }

    

    var body: some View {

        VStack {

            if isTabletopMode {

                // 상단: 미디어 화면

                Color.black

                    .overlay(

                        Text("Video Player (Top Screen)")

                            .foregroundColor(.white)

                    )

                    .frame(maxHeight: .infinity)

                

                // 하단: 조작 패널

                VStack(spacing: 20) {

                    Text("Controls (Bottom Screen)")

                        .font(.headline)

                    HStack(spacing: 40) {

                        Button(action: {}) { Image(systemName: "backward.fill") }

                        Button(action: {}) { Image(systemName: "play.fill") }

                        Button(action: {}) { Image(systemName: "forward.fill") }

                    }

                    .font(.title)

                }

                .frame(maxHeight: .infinity)

                .background(Color(.secondarySystemBackground))

            } else {

                // 일반 풀스크린 레이아웃

                VStack {

                    Text("Full Screen Display")

                        .font(.title)

                    Text("Hinge Angle: \(Int(hingeAngle))°")

                }

            }

        }

    }

}

4. Side Toolbar (수직 툴바) 레이아웃

iOS 27 및 iPhone Duo 표준에 추가된 수직 툴바 배치(toolbarVerticalEdge)를 적용하는 기법입니다.


Swift

import SwiftUI


struct Demo04_VerticalToolbarView: View {

    @State private var text = "iPhone Duo 수직 툴바 테스트"

    

    var body: some View {

        NavigationStack {

            TextEditor(text: $text)

                .padding()

                .navigationTitle("Note Editor")

                .toolbar {

                    // 오른쪽 사이드 수직 영역에 배치되는 툴바 항목들

                    ToolbarItemGroup(placement: .topBarTrailing) {

                        Button(action: {}) { Image(systemName: "bold") }

                        Button(action: {}) { Image(systemName: "italic") }

                        Button(action: {}) { Image(systemName: "underline") }

                        Button(action: {}) { Image(systemName: "square.and.arrow.up") }

                    }

                }

                // iPhone Duo 수직 바 배치 옵션

                .toolbarVerticalEdge(.trailing)

        }

    }

}

5. Dual Screen App Switching (ArrangementView / Screen Splitting)

화면 왼쪽과 오른쪽에 서로 다른 보조 앱 UI/뷰를 할당하는 Layout 패러다임입니다.


Swift

import SwiftUI


struct Demo05_DualWorkspaceView: View {

    var body: some View {

        GeometryReader { geometry in

            let isWideScreen = geometry.size.width > 600

            

            if isWideScreen {

                // 듀얼 스크린 각각 독립적인 워크스페이스

                HStack(spacing: 0) {

                    // Left Screen: Document List

                    NavigationStack {

                        List(1...5, id: \.self) { index in

                            Text("Document \(index)")

                        }

                        .navigationTitle("Files")

                    }

                    .frame(width: geometry.size.width / 2)

                    

                    Divider()

                    

                    // Right Screen: Preview Canvas

                    VStack {

                        Text("Live Preview Canvas")

                            .font(.title2)

                        Spacer()

                    }

                    .padding()

                    .frame(width: geometry.size.width / 2)

                }

            } else {

                // 단일 화면

                Text("Please unfold your iPhone Duo to use Dual Workspace.")

                    .padding()

            }

        }

    }

}

6. 힌지 물리 자원 회피 (Hinge Avoidance Layout)

힌지 영역(Hinge Boundary)을 감지하고, 중요한 UI 요소가 힌지 중앙 선에 잘리지 않도록 안전 패딩을 자동 부여합니다.


Swift

import SwiftUI


struct Demo06_HingeAvoidanceView: View {

    // 힌지가 위치한 중앙의 가상 경계 너비 (12pt 예시)

    let hingeWidth: CGFloat = 12.0

    

    var body: some View {

        GeometryReader { proxy in

            let totalWidth = proxy.size.width

            let sideWidth = (totalWidth - hingeWidth) / 2

            

            HStack(spacing: 0) {

                // 좌측 디스플레이 Safe Content

                VStack {

                    Text("Left Side UI")

                        .bold()

                    Spacer()

                }

                .frame(width: sideWidth)

                .background(Color.blue.opacity(0.1))

                

                // 힌지 회피 영역 (마진 처리)

                Color.clear

                    .frame(width: hingeWidth)

                

                // 우측 디스플레이 Safe Content

                VStack {

                    Text("Right Side UI")

                        .bold()

                    Spacer()

                }

                .frame(width: sideWidth)

                .background(Color.green.opacity(0.1))

            }

        }

    }

}

7. Duo Preview (커버 스크린 카메라이용 셀피 및 듀얼 프리뷰)

iPhone Duo의 Rear Camera 사용 시 외부 디스플레이에 Live Preview를 동시 출력하는 UI 구조 예시입니다.   

Apple


Swift

import SwiftUI


struct Demo07_DuoCameraPreviewView: View {

    @State private var isOuterPreviewActive = false

    

    var body: some View {

        VStack(spacing: 24) {

            // 메인 뷰파인더

            ZStack {

                RoundedRectangle(cornerRadius: 16)

                    .fill(Color.gray.opacity(0.3))

                Text("Main Camera Viewfinder")

            }

            .frame(height: 300)

            

            // Duo Preview 활성화 버튼

            Toggle("Duo Preview (Outer Screen Active)", isOn: $isOuterPreviewActive)

                .padding()

                .background(RoundedRectangle(cornerRadius: 12).stroke(Color.gray))

            

            if isOuterPreviewActive {

                Label("Outer Screen is displaying live selfie preview for subject.", systemImage: "display.2")

                    .font(.caption)

                    .foregroundColor(.blue)

            }

        }

        .padding()

    }

}

8. 적응형 그리드 시스템 (Adaptive Grid for Foldable Screens)

디스플레이를 열고 접을 때 열(Column) 개수가 2개에서 4개로 자연스럽게 늘어나는 그리드입니다.


Swift

import SwiftUI


struct Demo08_AdaptiveGridView: View {

    @Environment(\.horizontalSizeClass) var sizeClass

    

    // 펼침 여부에 따른 GridItem 동적 생성

    var columns: [GridItem] {

        if sizeClass == .regular {

            return Array(repeating: GridItem(.flexible(), spacing: 16), count: 4)

        } else {

            return Array(repeating: GridItem(.flexible(), spacing: 12), count: 2)

        }

    }

    

    var body: some View {

        ScrollView {

            LazyVGrid(columns: columns, spacing: 16) {

                ForEach(1...20, id: \.self) { index in

                    RoundedRectangle(cornerRadius: 12)

                        .fill(Color.indigo.opacity(0.6))

                        .frame(height: 100)

                        .overlay(

                            Text("Card \(index)")

                                .foregroundColor(.white)

                                .bold()

                        )

                }

            }

            .padding()

        }

    }

}

9. Continuum Transition (연속성 애니메이션)

커버 화면에서 보던 내용이 화면을 펼쳤을 때 끊김 없이 정교하게 확장되는 Transition 처리입니다.


Swift

import SwiftUI


struct Demo09_ContinuityView: View {

    @Namespace private var animation

    @Environment(\.horizontalSizeClass) var sizeClass

    

    var body: some View {

        VStack {

            if sizeClass == .compact {

                // Compact 모드: 카드 형태

                VStack {

                    RoundedRectangle(cornerRadius: 16)

                        .fill(Color.orange)

                        .matchedGeometryEffect(id: "card", in: animation)

                        .frame(height: 150)

                        .overlay(Text("Tap or Unfold to Expand").foregroundColor(.white))

                }

                .padding()

            } else {

                // Regular (Fold Unfolded) 모드: 풀 대시보드

                HStack(spacing: 20) {

                    RoundedRectangle(cornerRadius: 16)

                        .fill(Color.orange)

                        .matchedGeometryEffect(id: "card", in: animation)

                        .frame(width: 250, height: 300)

                        .overlay(Text("Expanded View").foregroundColor(.white))

                    

                    VStack(alignment: .leading, spacing: 12) {

                        Text("Detailed Analytics").font(.title).bold()

                        Text("Seamless continuum transition from cover screen to main inner display.")

                        Spacer()

                    }

                }

                .padding()

            }

        }

        .animation(.spring(response: 0.5, dampingFraction: 0.8), value: sizeClass)

    }

}

10. 종합 예제: iPhone Duo 멀티태스킹 메모 앱

Size Class, Split View, 힌지 대응 및 사이드 바 컨트롤까지 결합된 종합 교육용 데모 프로젝트입니다.


Swift

import SwiftUI


struct Demo10_DuoMasterApp: View {

    @Environment(\.horizontalSizeClass) var sizeClass

    @State private var notes: [String] = ["수업 준비하기", "SwiftUI Duo API 실습", "아이디어 스케치"]

    @State private var selectedNote: String?

    

    var body: some View {

        NavigationSplitView {

            List(notes, id: \.self, selection: $selectedNote) { note in

                Text(note)

            }

            .navigationTitle("Notes")

            .toolbar {

                Button(action: { notes.append("새 노트 \(notes.count + 1)") }) {

                    Image(systemName: "plus")

                }

            }

        } detail: {

            if let note = selectedNote {

                VStack {

                    TextEditor(text: .constant("\(note)의 상세 내용입니다."))

                        .padding()

                }

                .navigationTitle(note)

                .toolbar {

                    ToolbarItemGroup(placement: .topBarTrailing) {

                        Button(action: {}) { Image(systemName: "trash") }

                        Button(action: {}) { Image(systemName: "square.and.arrow.up") }

                    }

                }

                .toolbarVerticalEdge(sizeClass == .regular ? .trailing : .automatic)

            } else {

                Text("노트를 선택해주세요.")

                    .font(.title3)

                    .foregroundColor(.secondary)

            }

        }

    }

}

댓글 없음:

댓글 쓰기

참고: 블로그의 회원만 댓글을 작성할 수 있습니다.

아이폰 듀오에 대응하는 데모 #2

폴더블·듀얼 화면 iPhone Duo를 대상으로, SwiftUI 수업에서 순서대로 사용할 수 있는 데모 10개를 구성했습니다. 각 데모는 학습 목표 → 코드 → 단계별 설명 → 확인 과제 순서입니다. Apple 공식 안내에 따르면 Duo의 전체 화면...