본문 바로가기
카테고리 없음

[WWDC22] What’s new in SwiftUI

by 게게겍 2023. 1. 31.

What’s new in SwiftUI

  • 올해 SwiftUI 업데이트 요약

  • SwiftUI로 만든 시스템 요소들

  • Swift Chats
    • 상태기반으로 차트를 그리는 API
    • SwiftUI와 동일한 패러다임이 적용되었고, SwiftUI와 함께 사용 가능
    • 접근성 대응 완벽 지원
var body: some View {
	Chart(parthTaskRemaining) { task in
		BarMark(
			x: .value("Date", task.date, unit: .day),
			y: .value("Tasks Remaining", task.remainingCount)
		)
	}.padding()
}

var body: some View {
    Chart(partyTasksRemaining) {
        LineMark(
            x: .value("Date", $0.date, unit: .day),
            y: .value("Tasks Remaining", $0.remainingCount)
        )
        .foregroundStyle(by: .value("Category", $0.category))
    }
    .padding()
}

var body: some View {
    Chart(partyTasksRemaining) {
        LineMark(
            x: .value("Date", $0.date, unit: .day),
            y: .value("Tasks Remaining", $0.remainingCount)
        )
        .foregroundStyle(by: .value("Category", $0.category))
        .symbol(by: .value("Category", $0.category))
    }
    .padding()
}

var body: some View {
    Chart {
        ForEach(partyTasksRemaining) { task in
            LineMark(
                x: .value("Date", task.date, unit: .day),
                y: .value("Tasks Remaining", task.remainingCount)
            )
            .foregroundStyle(by: .value("Category", task.category))
            .symbol(by: .value("Category", task.category))
            .annotation(position: .leading) {
                Text("\(task.category.emoji)")
            }
        }

        RuleMark(y: .value("Value", 5))
            .foregroundStyle(.red)
            .lineStyle(StrokeStyle(lineWidth: 2.0, dash: [4, 5]))
            .annotation(position: .top, alignment: .trailing) {
                VStack(alignment: .trailing) {
                    Text("Today's Goal")
                    Text("Status: ✔︎")
                }
                .font(.caption)
                .foregroundColor(.gray)
                .padding(.trailing, 2)
            }
    }
}
  • Navigation and windows
    • navigation stack
      • 기존 NavigationLink와도 사용 가능
NavigationStack {
	List(foodItems) { item in
		NavigationLink {
			FoodDetailView(item: item)
		} label: {
			Label(item.title, image: item.iconName)
		}
	}
}

데이터 기반 네비게이션 스택 → 타입을 이용해서 스택을 쌓기

NavigationStack {
	List(foodItems) { item in
		NavigationLink(value: item) {
			Label(item.title, image: item.iconName)
		}
	}
	.navigationTitle("Party Food")
	.navigationDestination(for: FoodItem.self) { item in
			FoodDetailView(item: item)
	}
}

스택조작

@State private var selectedFoodItems: [FoodItem] = []

var body: some View {
	NavigationStack(path: $selectedFoodItems) {
		List(foodItems) { item in
			NavigationLink(value: item) {
				Label(item.title, image: item.iconName)
			}
		}
		.navigationTitle("Party Food")
		.navigationDestination(for: FoodItem.self) { item in
			FoodDetailView(item: item, path: $selectedFoodItems)
		}
	}
}

// .. FoodDetailView.body
 Button("Back to First Item") {
		selectedFoodItems.removeSubrange(1...)
}

split views

  • 기본 예제
    • 화면이 작아졌을 때는 자동으로 stack으로 변한다
@State private var selectedTask: PartyTask?

    var body: some View {
        NavigationSplitView {
            List(PartyTask.allCases, selection: $selectedTask) { task in
                NavigationLink(value: task) {
                    TaskLabel(task: task)
                }
						    .listItemTint(task.color)
            }
        } detail: {
            selectedTask.flatMap { $0.color } ?? .white
        }
    }

NavigationStack과의 합성도 가능

struct PartyPlannerHome: View {
    @State private var selectedTask: PartyTask?

    var body: some View {
        NavigationSplitView {
            List(PartyTask.allCases, selection: $selectedTask) { task in
                NavigationLink(value: task) {
                    TaskLabel(task: task)
                }
                .listItemTint(task.color)
            }
        } detail: {
            if case .food = selectedTask {
                FoodsListView() // 내부에 NavigationStack을 가지고 있음
            } else {
                selectedTask.flatMap { $0.color } ?? .white
            }
        }
    }
}

multi window

-  메인 인터페이스인 windowGroup와는 다른 개별 윈도우를 정의할 수 있게 됨

@main
struct PartyPlanner: App {
    var body: some Scene {
        WindowGroup("Party Planner") {
            PartyPlannerHome()
        }

        Window("Party Budget", id: "budget") {
            Text("Budget View")
        }
        .keyboardShortcut("0")
    }
}

새로운 window 열기

struct DetailView: View {
    @Environment(\.openWindow) var openWindow

    var body: some View {
        Text("Detail View")
            .toolbar {
                Button {
                    openWindow(id: "budget")
                } label: {
                    Image(systemName: "dollarsign")
                }
            }
    }
}
  • window 커스텀
    • 사용자가 수동으로 변경했을 경우, 이를 자동으로 기억함.
@main
struct PartyPlanner: App {
    var body: some Scene {
        WindowGroup("Party Planner") {
            PartyPlannerHome()
        }

        Window("Party Budget", id: "budget") {
            Text("Budget View")
        }
        .keyboardShortcut("0")
        .defaultPosition(.topLeading)
        .defaultSize(width: 220, height: 250)
    }
}

sheet 높이 조정 modifier

struct PartyPlannerHome: View {
    @State private var selectedTask: PartyTask?
    @State private var presented: Bool = false

    var body: some View {
        NavigationSplitView {
            List(PartyTask.allCases, selection: $selectedTask) { task in
                NavigationLink(value: task) {
                    TaskLabel(task: task)
                }
                .listItemTint(task.color)
            }
        } detail: {
            if case .food = selectedTask {
                FoodsListView()
            } else {
                selectedTask.flatMap { $0.color } ?? .white
            }
        }
        .sheet(isPresented: $presented) {
            Text("Budget View")
                .presentationDetents([.height(250), .medium])
                .presentationDragIndicator(.visible)
        }
    }
}
  • 올해부터 타겟 하나로 멀티플랫폼 지원이 가능해졌음
  • 메뉴바의 추가 화면도 swiftUI로 지원 가능(macOS Ventura부터)
    • 아예 메뉴바만 있는 앱도 만들 수 있다.
@main
struct PartyPlanner: App {
    var body: some Scene {
        Window("Party Budget", id: "budget") {
            Text("Budget View")
        }

        MenuBarExtra("Bulletin Board", systemImage: "quote.bubble") {
            BulletinBoard()
        }
        .menuBarExtraStyle(.window)
    }
}

@main
struct MessageBoard: App {
    var body: some Scene {
        MenuBarExtra("Bulletin Board", systemImage: "quote.bubble") {
            BulletinBoard()
        }
        .menuBarExtraStyle(.window)
    }
}
  • Advanced controls → macos Ventura의 세팅앱이 이를 기반으로 완전히 변경됨
    • Forms - 스타일 변경
 Form {
            Section {
                LabeledContent("Location", value: address)
                DatePicker("Date", selection: $date)
                TextField("Description", text: $eventDescription, axis: .vertical)
                    .lineLimit(3, reservesSpace: true)
            }

            Section("Vibe") {
                Picker("Accent color", selection: $accent) {
                    ForEach(Theme.allCases) { theme in
                        Text(theme.rawValue.capitalized).tag(theme)
                    }
                }
                Picker("Color scheme", selection: $scheme) {
                    Text("Light").tag(ColorScheme.light)
                    Text("Dark").tag(ColorScheme.dark)
                }
                #if os(macOS)
                .pickerStyle(.inline)
                #endif
                Toggle(isOn: $extraGuests) {
                    Text("Allow extra guests")
                    Text("The more the merrier!")
                }
                if extraGuests {
                    Stepper("Guests limit", value: $spacesCount, format: .number)
                }
            }

            Section("Decorations") {
                Section {
                    List(selection: $selectedDecorations) {
                        DisclosureGroup {
                            HStack {
                                Toggle("Balloons 🎈", isOn: $includeBalloons)
                                Spacer()
                                decorationThemes[.balloon].map { $0.swatch }
                            }
                            .tag(Decoration.balloon)

                            HStack {
                                Toggle("Confetti 🎊", isOn: $includeConfetti)
                                Spacer()
                                decorationThemes[.confetti].map { $0.swatch }
                            }
                            .tag(Decoration.confetti)

                            HStack {
                                Toggle("Inflatables 🪅", isOn: $includeInflatables)
                                Spacer()
                                decorationThemes[.inflatables].map { $0.swatch }
                            }
                            .tag(Decoration.inflatables)

                            HStack {
                                Toggle("Party Horns 🥳", isOn: $includeBlowers)
                                Spacer()
                                decorationThemes[.noisemakers].map { $0.swatch }
                            }
                            .tag(Decoration.noisemakers)
                        } label: {
                            Toggle("All Decorations", isOn: [
                                $includeBalloons, $includeConfetti,
                                $includeInflatables, $includeBlowers
                            ])
                            .tag(Decoration.all)
                        }
                        #if os(macOS)
                        .toggleStyle(.checkbox)
                        #endif
                    }

                    Picker("Decoration theme", selection: themes) {
                        Text("Blue").tag(Theme.blue)
                        Text("Black").tag(Theme.black)
                        Text("Gold").tag(Theme.gold)
                        Text("White").tag(Theme.white)
                    }
                    #if os(macOS)
                    .pickerStyle(.radioGroup)
                    #endif
                }
            }

        }
        .formStyle(.grouped)
    }
  • Controls
    • 멀티라인 텍스트 필드 - 높이는 라인수에 따라 자동으로 조정됨
Textfield("Description", text: $description, axis: .vertical)
	.lineLimit(5...10) // 5줄 만큼은 미리 확보, 10줄 이상은 스크롤됨

다중 dataPicker

@State private var activityDates: Set<DateComponents> = [
        DateComponents(calendar: .current, year: 2022, month: 6, day: 6),
        DateComponents(calendar: .current, year: 2022, month: 6, day: 9),
        DateComponents(calendar: .current, year: 2022, month: 6, day: 10)
    ]

Section("Dates") {
    MultiDatePicker("Activities Dates", selection: $activityDates)
}
  • 여러 상태가 섞인 토글과 피커
  • 이제는 버튼뿐 아니라 버튼 같이 생긴 control들에도 buttomStyle 적용가능(toggle, menu, picker)
  • stepper에 포맷 인자 추가 및 watchOS 지원
Stepper("Guest limit", value: $guestLimit, format: .number)

Accessibility Quick Action을 일반 뷰와 동일하게 적용 가능

var body: some View {
        VStack(alignment: .leading) {
            ItemDescriptionView()
            addToCartButton
        }
        .accessibilityQuickAction(style: .prompt) {
            addToCartButton
        }
    }

    var addToCartButton: some View {
        Button(isInCart ? "Remove from cart" : "Add to cart") {
            isInCart.toggle()
        }
    }
}
  • Tables
    • iPadOS에서 테이블 지원.(macOS에서는 작년에 제공되기 시작한 것)
      • 작은 사이즈(iPhone 등)일때는 첫번째 컬럼만 그려진다.
@State private var attendees: [Attendee]

var body: some View {
	Table(attendees) {
		TableColumn("Name") { attendee in
			AttendeeRow(attendee)
		}
		
		TableColumn("City", value: \.city)
		TableColumn("Status) { attendee in 
			StatusRow(attendee)
		}
	}
}

macOS에서는 테이블의 컨텍스트 메뉴도 쉽게 지정 가능

#if os(macOS)
.contextMenu(forSelectionType: Attendee.ID.self) { selection in
    if selection.isEmpty {
        Button("New Invitation") { addInvitation() }
    } else if selection.count == 1 {
        Button("Mark as VIP") { markVIPs(selection) }
    } else {
        Button("Mark as VIPs") { markVIPs(selection) }
    }
}
#endif
  • 툴바 커스텀 기능(iPadOS, macOS)
    • id 기반으로 커스텀 툴바 설정을 swiftUI가 기억한다.
    • secondaryAction으로 지정해야만 커스텀이 가능하다.
.toolbar(id: "toolbar") {
    ToolbarItem(id: "new", placement: .secondaryAction) {
        Button(action: {}) {
            Label("New Invitation", systemImage: "envelope")
        }
    }
    ToolbarItem(id: "edit", placement: .secondaryAction) {
        Button(action: {}) {
            Label("Edit", systemImage: "pencil.circle")
        }
    }
    ToolbarItem(id: "share", placement: .secondaryAction) {
        Button(action: {}) {
            Label("Share", systemImage: "square.and.arrow.up")
        }
    }
    ToolbarItem(id: "tag", placement: .secondaryAction) {
        Button(action: {}) {
            Label("Tags", systemImage: "tag")
        }
    }
    ToolbarItem(
        id: "reminder", placement: .secondaryAction, showsByDefault: false
    ) {
        Button(action: {}) {
            Label("Set reminder", systemImage: "bell")
        }
    }
}
.toolbarRole(.editor)
  • 검색창 지원 추가
    • 토큰 검색 추가
    • 스코프 기능 추가 - 검색창 아래에 뜬다.
@State private var queryText: String
@State private var queryTokens: [InvitationToken]
@State private var scope: AttendanceScope

var body: some View {
	InvitationsContentView()
		.searchbars(text: $queryText, tokens: $queryTokens, scope: $scope) { token in
				Label(token.displayName, systemImage: token.systemImage)
		} scopes: {
			Text("In Person").tag(AttendanceScope.inPerson)
			Text("Online").tag(AttendanceScope.online)
		}
}
  • Sharing
    • Photos → 단일 선택용과 다중 선택용 모두 제공
var body: some View {
    NavigationStack {
        Gallery()
            .navigationTitle("Birthday Filter")
            .toolbar {
                PhotosPicker(
                    selection: $viewModel.imageSelection,
                    matching: .images
                ) {
                    Label("Pick a photo", systemImage: "plus.app")
                }
                Button {
                    viewModel.applyFilter()
                } label: {
                    Label("Apply Filter", systemImage: "camera.filters")
                }
            }
    }
}

Sharing

.toolbar {
    PhotosPicker(
        selection: $viewModel.imageSelection,
        matching: .images
    ) {
        Label("Pick a photo", systemImage: "plus.app")
    }
    Button {
        viewModel.applyFilter()
    } label: {
        Label("Apply Filter", systemImage: "camera.filters")
    }
    if let item = viewModel.processedImage {
        ShareLink(
            item: item, preview: SharePreview("Birthday Effects"))
    }
}
  • Transferable: Photos와 Sharing의 기반
    • 스위프트로 만들어진, 특정 타입이 어떻게 앱 간에 전송될 수 있는지는 나타내는 방법
    • 드래그 앤 드롭 등에 활용됨
  •  
// 어떤 타입의 데이터를 드래그 앤 드롭 했을 때 반응할지를 명시하면
// 드롭된 데이터와, 그 위치값을 콜백에서 받음
.dropDestination(payloadType: Image.self) { receivedImages, location in
    guard let image = receivedImages.first else {
        return false
    }
    viewModel.imageState = .success(image)
    return true
}
  • 표준 Transferable 타입
    • String
    • Data
    • URL
    • AttributedString
    • Image
    • 등등…
  • 커스텀 타입도 가능 → 필요한 조건 등의 세부 사항은 다른 세션에서
  • Graphics and layout
    • ShapeStyle
      • gradient 넣기, 그림자 넣기 효과 추가 →
struct CalendarIcon: View {
    var body: some View {
        VStack {
            Image(systemName: "calendar")
                .font(.system(size: 80, weight: .medium))
            Text("June 6")
        }
        .background(in: Circle().inset(by: -20))
        .backgroundStyle(
            .blue
            .gradient
        )
        .foregroundStyle(.white.shadow(.drop(radius: 1, y: 1.5)))
        .padding(20)
    }
}
  • 텍스트 및 이미지 트랜지션
    • 특히 텍스트, weight, style, layout 모두 쉽게 애니매이션이 된다.
struct TextTransitionsView: View {
    @State private var expandMessage = true
    private let mintWithShadow: AnyShapeStyle = AnyShapeStyle(Color.mint.shadow(.drop(radius: 2)))
    private let primaryWithoutShadow: AnyShapeStyle = AnyShapeStyle(Color.primary.shadow(.drop(radius: 0)))

    var body: some View {
        Text("Happy Birthday SwiftUI!")
            .font(expandMessage ? .largeTitle.weight(.heavy) : .body)
            .foregroundStyle(expandMessage ? mintWithShadow : primaryWithoutShadow)
            .onTapGesture { withAnimation { expandMessage.toggle() }}
            .frame(maxWidth: expandMessage ? 160 : 250)
            .drawingGroup()
            .padding(20)
            .background(.pink.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
    }
}
  • Layout
    • preview Variant를 통해서 코드로 작성하지 않고도 다양한 버전의 프리뷰 확인 가능
    • 프리뷰가 기본적으로 라이브모드로 동작함
    • Grid 레이아웃 추가.
struct VIPDetailView: View {
    var body: some View {
        Grid {
            GridRow {
                NameHeadline()
                    .gridCellColumns(2)
            }
            GridRow {
                CalendarIcon()
                SymbolGrid()
            }
        }
        .frame(width: 300, height: 300)
    }
}

커스텀 레이아웃을 짜기 위한 API들을 공개