SwiftUI in more places
headset부터 watch, 위젯과 크로스플랫폼 통합까지
공간 컴퓨팅에서도 SwiftUI가 사용되고 이를 위한 3D 기능이 추가되었다.
홈 뷰부터 TV, Safari, Freeform, Keynote의 immersive rehearsal 기능 등이 모두 swiftUI로 구성되어 있다.
똑같이 WindowGroup을 쓰면 2D Window를 그려준다.
그 안에서 똑같이 SwiftUI 컨테이너를 써주면 된다.
여기에 더해서 Scene을 3D로 만들 수도 있다.
정적 모델로 채우기 - Model3D
동적으로 상호작용이 가능한 뷰 - RealityView
새로운 Scene인 ImmersiveSpace 추가
watchOS 10 리뉴얼
기존 뷰 컨테이너 리뉴얼
신규 API 추가
toolbar 아이템 위치 조정
일부 API가 WatchOS에서도 사용 가능하게 됨.
DatePicker
Selection in List
Widget
watchOS에 Smart Stack으로 위젯을 빠르게 볼 수있음
iPadOS에 Lock Screen Widget 추가
iPhone의 AOD를 활용한 Standby mode
macOS의 desktop widget
interactive 지원
Widget을 위한 preview매크로
#Preview(as: .systemSmall) {
CaffeineTrackerWidget()
} timeline : {
CaffeineLogEntry.log1
CaffeineLogEntry.log2
CaffeineLogEntry.log3
CaffeineLogEntry.log4
}
Apple 프레임워크들의 SwiftUI 지원 추가
MapKit
Swift Chart
차트 스크롤링
import SwiftUI
import Charts
struct ScrollingChart_Snippet: View {
@State private var scrollPosition = SalesData.last365Days.first!
@State private var selection: SalesData?
var body: some View {
VStack(alignment: .leading) {
VStack(alignment: .leading) {
Text("""
Scrolled to: \\
\\(scrollPosition.day,
format: .dateTime.day().month().year())
""")
Text("""
Selected: \\
\\(selection?.day ?? .now,
format: .dateTime.day().month().year())
""")
.opacity(selection != nil ? 1.0 : 0.0)
}
.padding([.leading, .trailing])
Chart {
ForEach(SalesData.last365Days, id: \\.day) {
BarMark(
x: .value("Day", $0.day, unit: .day),
y: .value("Sales", $0.sales))
}
.foregroundStyle(.blue)
}
.chartScrollableAxes(.horizontal)
.chartXVisibleDomain(length: 3600 * 24 * 30)
.chartScrollPosition(x: $scrollPosition)
.chartXSelection(value: $selection)
}
}
}
struct SalesData: Plottable {
var day: Date
var sales: Int
var primitivePlottable: Date { day }
init?(primitivePlottable: Date) {
self.day = primitivePlottable
self.sales = 0
}
init(day: Date, sales: Int) {
self.day = day
self.sales = sales
}
static let last365Days: [SalesData] = buildSalesData()
private static func buildSalesData() -> [SalesData] {
var result: [SalesData] = []
var date = Date.now
for _ in 0..<365 {
result.append(SalesData(day: date, sales: Int.random(in: 150...250)))
date = Calendar.current.date(
byAdding: .day, value: -1, to: date)!
}
return result.reversed()
}
}
#Preview {
ScrollingChart_Snippet()
}
selection 지원
도넛 차트와 파이 차트 지원(SectorMark)
import SwiftUI
import Charts
struct DonutChart_Snippet: View {
var sales = Bagel.salesData
var body: some View {
NavigationStack {
Chart(sales, id: \\.name) { element in
SectorMark(
angle: .value("Sales", element.sales),
innerRadius: .ratio(0.6),
angularInset: 1.5)
.cornerRadius(5)
.foregroundStyle(by: .value("Name", element.name))
}
.padding()
.navigationTitle("Bagel Sales")
.toolbarTitleDisplayMode(.inlineLarge)
}
}
}
struct Bagel {
var name: String
var sales: Int
static var salesData: [Bagel] = buildSalesData()
static func buildSalesData() -> [Bagel] {
[
Bagel(name: "Blueberry", sales: 60),
Bagel(name: "Everything", sales: 120),
Bagel(name: "Choc. Chip", sales: 40),
Bagel(name: "Cin. Raisin", sales: 100),
Bagel(name: "Plain", sales: 140),
Bagel(name: "Onion", sales: 70),
Bagel(name: "Sesame Seed", sales: 110),
]
}
}
#Preview {
DonutChart_Snippet()
}
StoreKit
import SwiftUI
import StoreKit
struct SubscriptionStore_Snippet {
var body: some View {
SubscriptionStoreView(groupID: passGroupID) {
PassMarketingContent()
.lightMarketingContentStyle()
.containerBackground(for: .subscriptionStoreFullHeight) {
SkyBackground()
}
}
.backgroundStyle(.clear)
.subscriptionStoreButtonLabel(.multiline)
.subscriptionStorePickerItemBackground(.thinMaterial)
.storeButton(.visible, for: .redeemCode)
}
}
Simplify data flow
Observable 매크로
일일이 Published를 할 필요가 없다.
import Foundation
import SwiftUI
@Observable
class Dog: Identifiable {
var id = UUID()
var name = ""
var age = 1
var breed = DogBreed.mutt
var owner: Person? = nil
}
class Person: Identifiable {
var id = UUID()
var name = ""
}
enum DogBreed {
case mutt
}
사용할 때도 프로퍼티 래퍼가 필요없고, 의존성을 자동으로 설정한다.
실제로 읽는 프로퍼티만 재평가하기 때문에 중간 뷰가 불필요하게 reevaluation되지 않는다.
import Foundation
import SwiftUI
struct DogCard: View {
var dog: Dog
var body: some View {
DogImage(dog: dog)
.overlay(alignment: .bottom) {
HStack {
Text(dog.name)
Spacer()
Image(systemName: "heart")
.symbolVariant(dog.isFavorite ? .fill : .none)
}
.font(.headline)
.padding(.horizontal, 22)
.padding(.vertical, 12)
.background(.thinMaterial)
}
.clipShape(.rect(cornerRadius: 16))
}
struct DogImage: View {
var dog: Dog
var body: some View {
Rectangle()
.fill(Color.green)
.frame(width: 400, height: 400)
}
}
@Observable
class Dog: Identifiable {
var id = UUID()
var name = ""
var isFavorite = false
}
}
기존에 ObservableObject와 함께 동작하던 PropertyWrapper들은 모두 필요없다.
State와 Binding, Environment만 남는다. Observable과 함께 잘 동작한다.
// State
import Foundation
import SwiftUI
struct AddSightingView: View {
@State private var model = DogDetails()
var body: some View {
Form {
Section {
TextField("Name", text: $model.dogName)
DogBreedPicker(selection: $model.dogBreed)
}
Section {
TextField("Location", text: $model.location)
}
}
}
struct DogBreedPicker: View {
@Binding var selection: DogBreed
var body: some View {
Picker("Breed", selection: $selection) {
ForEach(DogBreed.allCases) {
Text($0.rawValue.capitalized)
.tag($0.id)
}
}
}
}
@Observable
class DogDetails {
var dogName = ""
var dogBreed = DogBreed.mutt
var location = ""
}
enum DogBreed: String, CaseIterable, Identifiable {
case mutt
case husky
case beagle
var id: Self { self }
}
}
#Preview {
AddSightingView()
}
// Environment
import SwiftUI
@main
private struct WhatsNew2023: App {
@State private var currentUser: User?
var body: some Scene {
WindowGroup {
ContentView()
.environment(currentUser)
}
}
struct ContentView: View {
var body: some View {
Color.clear
}
}
struct ProfileView: View {
@Environment(User.self) private var currentUser: User?
var body: some View {
if let currentUser {
UserDetails(user: currentUser)
} else {
Button("Log In") { }
}
}
}
struct UserDetails: View {
var user: User
var body: some View {
Text("Hello, \\(user.name)")
}
}
@Observable
class User: Identifiable {
var id = UUID()
var name = ""
}
}
SwiftData
Persist 기능을 더해준다.
Observable 매크로를 Model로 바꿔주면 된다
Query 매크로를 통해서 데이터를 가져올 수 있다.
import Foundation
import SwiftUI
import SwiftData
struct RecentDogsView: View {
@Query(sort: \\.dateSpotted) private var dogs: [Dog]
var body: some View {
ScrollView(.vertical) {
LazyVStack {
ForEach(dogs) { dog in
DogCard(dog: dog)
}
}
}
}
struct DogCard: View {
var dog: Dog
var body: some View {
DogImage(dog: dog)
.overlay(alignment: .bottom) {
HStack {
Text(dog.name)
Spacer()
Image(systemName: "heart")
.symbolVariant(dog.isFavorite ? .fill : .none)
}
.font(.headline)
.padding(.horizontal, 22)
.padding(.vertical, 12)
.background(.thinMaterial)
}
.clipShape(.rect(cornerRadius: 16))
}
}
struct DogImage: View {
var dog: Dog
var body: some View {
Rectangle()
.fill(Color.green)
.frame(width: 400, height: 400)
}
}
@Model
class Dog: Identifiable {
var name = ""
var isFavorite = false
var dateSpotted = Date.now
}
}
#Preview {
RecentDogsView()
}
Document기반 앱에서도 네이티브로 지원
import SwiftUI
import SwiftData
import UniformTypeIdentifiers
@main
private struct WhatsNew2023: App {
var body: some Scene {
DocumentGroup(editing: DogTag.self, contentType: .dogTag) {
ContentView()
}
}
struct ContentView: View {
var body: some View {
Color.clear
}
}
@Model
class DogTag {
var text = ""
}
}
extension UTType {
static var dogTag: UTType {
UTType(exportedAs: "com.apple.SwiftUI.dogTag")
}
}
Inspector 지원
Dialog용 modifier 추가
file Export
import Foundation
import SwiftUI
import UniformTypeIdentifiers
struct ExportDialogCustomization: View {
@State private var isExporterPresented = true
@State private var selectedItem = ""
var body: some View {
Color.clear
.fileExporter(
isPresented: $isExporterPresented, item: selectedItem,
contentTypes: [.plainText], defaultFilename: "ExportedData.txt")
{ result in
handleDataExport(result: result)
}
.fileExporterFilenameLabel("Export Data")
.fileDialogConfirmationLabel("Export Data")
}
func handleDataExport(result: Result<URL, Error>) {
}
struct Data: Codable, Transferable {
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .plainText)
}
var text = "Exported Data"
}
}
dialog
import Foundation
import SwiftUI
import UniformTypeIdentifiers
struct ConfirmationDialogCustomization: View {
@State private var showDeleteDialog = false
@AppStorage("dialogIsSuppressed") private var dialogIsSuppressed = false
var body: some View {
Button("Show Dialog") {
if !dialogIsSuppressed {
showDeleteDialog = true
}
}
.confirmationDialog(
"Are you sure you want to delete the selected dog tag?",
isPresented: $showDeleteDialog)
{
Button("Delete dog tag", role: .destructive) { }
HelpLink { ... }
}
.dialogSeverity(.critical)
.dialogSuppressionToggle(isSuppressed: $dialogIsSuppressed)
}
}
table
열 정렬 및 숨기기 지원
SceneStorage와 결합하면 앱을 껏다 켜도 유지된다.
import SwiftUI
struct DogSightingsTable: View {
private var dogSightings: [DogSighting] = (1..<50).map {
.init(
name: "Sighting \\($0)",
date: .now + Double((Int.random(in: -5..<5) * 86400)))
}
@SceneStorage("columnCustomization")
private var columnCustomization: TableColumnCustomization<DogSighting>
@State private var selectedSighting: DogSighting.ID?
var body: some View {
Table(
dogSightings, selection: $selectedSighting,
columnCustomization: $columnCustomization)
{
TableColumn("Dog Name", value: \\.name)
.customizationID("name")
TableColumn("Date") {
Text($0.date, style: .date)
}
.customizationID("date")
}
}
struct DogSighting: Identifiable {
var id = UUID()
var name: String
var date: Date
}
}
DisclosureTableRow: 다른 Row를 가지고 있는 Row
import SwiftUI
struct DogGenealogyTable: View {
private static let dogToys = ["🦴", "🧸", "👟", "🎾", "🥏"]
private var dogs: [DogGenealogy] = (1..<10).map {
.init(
name: "Parent \\($0)", age: Int.random(in: 8..<12) * 7,
favoriteToy: dogToys[Int.random(in: 0..<5)],
children: (1..<10).map {
.init(
name: "Child \\($0)", age: Int.random(in: 1..<5) * 7,
favoriteToy: dogToys[Int.random(in: 0..<5)])
}
)
}
var body: some View {
Table(of: DogGenealogy.self) {
TableColumn("Dog Name", value: \\.name)
TableColumn("Age (Dog Years)") {
Text($0.age, format: .number)
}
TableColumn("Favorite Toy", value: \\.favoriteToy)
} rows: {
ForEach(dogs) { dog in
DisclosureTableRow(dog) {
ForEach(dog.children) { child in
TableRow(child)
}
}
}
}
}
struct DogGenealogy: Identifiable {
var id = UUID()
var name: String
var age: Int
var favoriteToy: String
var children: [DogGenealogy] = []
}
}
코드로 열 펼치기 기능 지원
import SwiftUI
struct ExpandableSectionsView: View {
@State private var selection: Int?
var body: some View {
NavigationSplitView {
Sidebar(selection: $selection)
} detail: {
Detail(selection: selection)
}
}
struct Sidebar: View {
@Binding var selection: Int?
@State private var isSection1Expanded = true
@State private var isSection2Expanded = false
var body: some View {
List(selection: $selection) {
Section("First Section", isExpanded: $isSection1Expanded) {
ForEach(1..<6, id: \\.self) {
Text("Item \\($0)")
}
}
Section("Second Section", isExpanded: $isSection2Expanded) {
ForEach(6..<11, id: \\.self) {
Text("Item \\($0)")
}
}
}
}
}
struct Detail: View {
var selection: Int?
var body: some View {
Text(selection.map { "Selection: \\($0)" } ?? "No Selection")
}
}
}
추가 스타일링
Extraordinary animations
Enhanced interactions
HDR 이미지 지원
accessibilityZoomAction
Menu 강화