[WWDC24] SwiftUIの新機能のまとめ

2024年6月25日 所 友太 / @tokorom

image

WWDC24の What’s new in SwiftUI のまとめです。

今回、このセッションで紹介される項目の数が例年以上に多すぎてびっくりでした。 セッションでは短い間隔でポンポンとたくさんの機能が流れるように紹介されていきます。

このまとめでは、セッションでは軽く触れられた程度の内容も、APIリファレンスへのリンクをつけるなどしてもう少しだけ補足します。

このセッションを視聴する/この記事を参照する目的は、WWDC24で発表されたSwiftUIの新機能をさらっと把握し頭の中にインデックスを貼ることだと思います。

サイドバー/タブバー

sidebar-customize

struct KaraokeTabView: View {
    @State var customization = TabViewCustomization()
    
    var body: some View {
        TabView {
            Tab("Parties", image: "party.popper") {
                PartiesView(parties: Party.all)
            }
            .customizationID("karaoke.tab.parties")
            
            Tab("Planning", image: "pencil.and.list.clipboard") {
                PlanningView()
            }
            .customizationID("karaoke.tab.planning")

            Tab("Attendance", image: "person.3") {
                AttendanceView()
            }
            .customizationID("karaoke.tab.attendance")

            Tab("Song List", image: "music.note.list") {
                SongListView()
            }
            .customizationID("karaoke.tab.songlist")
        }
        .tabViewStyle(.sidebarAdaptable)
        .tabViewCustomization($customization)
    }
}

tvos-sidebar

macos-segmented

sheet

.presentationSizing(
    .page
        .fitted(horizontal: false, vertical: true)
        .sticky(horizontal: false, vertical: true)
)

Zoom Navigation Transition

struct PartyView: View {
    var party: Party
    @Namespace() var namespace
    
    var body: some View {
        NavigationLink {
            PartyDetailView(party: party)
                .navigationTransition(.zoom(
                    sourceID: party.id, in: namespace))
        } label: {
            Text("Party!")
        }
        .matchedTransitionSource(id: party.id, in: namespace)
    }
}

struct PartyDetailView: View {
    var party: Party
    
    var body: some View {
        Text("PartyDetailView")
    }
}

struct Party: Identifiable {
    var id = UUID()
    static var all: [Party] = []
}

Controls

controls

import WidgetKit
import SwiftUI

struct StartPartyControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(
            kind: "com.apple.karaoke_start_party"
        ) {
            ControlWidgetButton(action: StartPartyIntent()) {
                Label("Start the Party!", systemImage: "music.mic")
                Text(PartyManager.shared.nextParty.name)
            }
        }
    }
}

// Model code

class PartyManager {
    static let shared = PartyManager()
    var nextParty: Party = Party(name: "WWDC Karaoke")
}

struct Party {
    var name: String
}

// AppIntent

import AppIntents

struct StartPartyIntent: AppIntent {
    static let title: LocalizedStringResource = "Start the Party"
    
    func perform() async throws -> some IntentResult {
        return .result()
    }
}

Swift Charts

lineplot

import SwiftUI
import Charts

struct AttendanceView: View {
    var body: some View {
        Chart {
          LinePlot(x: "Parties", y: "Guests") { x in
            pow(x, 2)
          }
          .foregroundStyle(.purple)
        }
        .chartXScale(domain: 1...10)
        .chartYScale(domain: 1...100)
    }
}

TableColumnForEach

tablecolumnforeach

Table(guestData) {
    // A static column for the name
    TableColumn("Name", value: \.name)
    
    TableColumnForEach(partyData) { party in
        TableColumn(party.name) { guest in
            Text(guest.songsSung[party.id] ?? 0, format: .number)
        }
    }
}

MeshGradient

meshgradient

MeshGradient(
    width: 3,
    height: 3,
    points: [
        .init(0, 0), .init(0.5, 0), .init(1, 0),
        .init(0, 0.5), .init(0.3, 0.5), .init(1, 0.5),
        .init(0, 1), .init(0.5, 1), .init(1, 1)
    ],
    colors: [
        .red, .purple, .indigo,
        .orange, .cyan, .blue,
        .yellow, .green, .mint
    ]
)

Document Launch Scene

DocumentGroupLaunchScene

DocumentGroupLaunchScene("Your Lyrics") {
    NewDocumentButton()
    Button("New Parody from Existing Song") {
        // Do something!
    }
} background: {
    PinkPurpleGradient()
} backgroundAccessoryView: { geometry in
    MusicNotesAccessoryView(geometry: geometry)
         .symbolEffect(.wiggle(.rotational.continuous()))
} overlayAccessoryView: { geometry in
    MicrophoneAccessoryView(geometry: geometry)
}

SF Symbols

macOS

Window

window-plain

Window("Lyric Preview", id: "lyricPreview") { ... }
    .windowStyle(.plain)
    .windowLevel(.floating)
.defaultWindowPlacement { content, context in
    let displayBounds = context.defaultDisplay.visibleRect
    let contentSize = content.sizeThatFits(.unspecified)
    return topPreviewPlacement(size: contentSize, bounds: displayBounds)
}

UtilityWindow

modifierKeyAlternate

modifierKeyAlternate

Button("Preview Lyrics in Window") {
    // show preview in window
}
.modifierKeyAlternate(.option) {
    Button("Preview Lyrics in Full Screen") {
        // show preview in full screen
    }
}
.keyboardShortcut("p", modifiers: [.shift, .command])

onModifierKeysChanged

LyricLine()
    .overlay(alignment: .top) {
        if showBouncingBallAlignment {
            // Show bouncing ball alignment guide
        }
    }
    .onModifierKeysChanged(mask: .option) {
        showBouncingBallAlignment = !$1.isEmpty
    }

pointerStyle

pointerStyle

ForEach(resizeAnchors) { anchor in
    ResizeHandle(anchor: anchor)
         .pointerStyle(.frameResize(position: anchor.position))
}

visionOS

pushWindow

struct EditorView: View {
    @Environment(\.pushWindow) private var pushWindow
    
    var body: some View {
        Button("Play", systemImage: "play.fill") {
            pushWindow(id: "lyric-preview")
        }
    }
}

hoverEffect

hoverEffect

.hoverEffect { effect, isActive, _ in
    effect.scaleEffect(isActive ? 1.05 : 1.0)
}

iPadOS

スクイーズ

@Environment(\.preferredPencilSqueezeAction) var preferredAction
    
var body: some View {
    LyricsEditorView()
        .onPencilSqueeze { phase in
            if preferredAction == .showContextualPalette, case let .ended(value) = phase {
                if let anchorPoint = value.hoverPose?.anchor {
                    lyricDoodlePaletteAnchor = .point(anchorPoint)
                }
                lyricDoodlePalettePresented = true
            }
       }
}

watchOS

Live Activity

ダブルタップジェスチャー

時間や日付の表示形式

textFormat

ウィジェットの表示示唆

SwiftUI Framework foundations

カスタムコンテナ

customContainer

Entryマクロ

extension EnvironmentValues {
  @Entry var karaokePartyColor: Color = .purple
}

extension FocusValues {
  @Entry var lyricNote: String? = nil
}

extension Transaction {
  @Entry var animatePartyIcons: Bool = false
}

extension ContainerValues {
  @Entry var displayBoardCardStyle: DisplayBoardCardStyle = .bordered
}

アクセシビリティ

Xcode Preview

#Preview {
   @Previewable @State var showAllSongs = true
   Toggle("Show All songs", isOn: $showAllSongs)
}

テキストの選択範囲

struct LyricView: View {
  @State private var selection: TextSelection?
  
  var body: some View {
    TextField("Line \(line.number)", text: $line.text, selection: $selection)
    // ...
  }
}

searchFocused

textInputSuggestions

textSuggestions

色の混合

colorMix

カスタムシェーダー

ContentView()
  .task {
    let slimShader = ShaderLibrary.slim()
    try! await slimShader.compile(as: .layerEffect)
  }

ScrollView

ScrollView {
    // ...
  }
  .onScrollGeometryChange(for: Bool.self) { geometry in
    geometry.contentOffset.y < geometry.contentInsets.top
  } action: { wasScrolledToTop, isScrolledToTop in
    withAnimation {
      showBackButton = !isScrolledToTop
    }
  }
VideoPlayer(player: player)
  .onScrollVisibilityChange(threshold: 0.2) { visible in
    if visible {
      player.play()
    } else {
      player.pause()
    }
  }
struct ContentView: View {
  @State private var position: ScrollPosition =
    .init(idType: Int.self)

  var body: some View {
    ScrollView {
      // ... 
    }
    .scrollPosition($position)
    .overlay {
      FloatingButton("Back to Invitation") {
        position.scrollTo(edge: .top)
      }
    }
  }
}

Swift 6

UIKit/AppKit連携

visionOS 2

Volumeベースプレート

WindowGroup {
  ContentView()
}
.windowStyle(.volumetric)
.defaultWorldScaling(.trueScale)
.volumeBaseplateVisibility(.hidden)

onVolumeViewpointChange

 Model3D(named: "microphone")
  .onVolumeViewpointChange { _, new in
    micRotation = rotateToFace(new)
  }
  .rotation3DEffect(micRotation)
  .animation(.easeInOut, value: micRotation)

没入度の範囲指定

immersiveProgerssive

新しいSurroundingsEffect

struct LoungeView: View {
  var body: some View {
    StageView()
      .preferredSurroundingsEffect(.colorMultiply(.purple))
  }
}

オーナメント

テキストViewの拡張

textRender

struct KaraokeRenderer: TextRenderer {
  func draw(
    layout: Text.Layout,
    in context: inout GraphicsContext
  ) {
    for line in layout {
      for run in line {
        var glow = context

        glow.addFilter(.blur(radius: 8))
        glow.addFilter(purpleColorFilter)

        glow.draw(run)
        context.draw(run)
      }
    }
  }
}

struct LyricsView: View {
  var body: some View {
    Text("A Whole View World")
      .textRenderer(KaraokeRenderer())
  }
}

まとめは以上です。 盛りだくさんすぎてびっくりでした。

Related Entries
Latest Entries
すべての記事一覧をみる