SwiftUIの新機能 〜その2〜 – WWDC2020

Session概要

SwiftUIの新機能 〜その1〜 では、SwiftUIのprotocol(App, Scene)についてやDocument Groupについて軽く触れ、コマンドモディファイアやWidget、及びGridの簡単なサンプルを紹介しました。
ここでは、その他UI componentのためのAPIについて紹介した後、styleやアニメーションについて説明し、最後にシステムの統合方法について紹介します。

ツールバーやコントロールのためのAPI

ツールバーモディファイア

  • ツールバーの表示をカスタマイズする
  • ToolBarItemsはSwiftUIの他の部分と同じViewで構成されている
  • セマンティックプレースメント
    • ToolBarItemの役割をSwiftUIが理解し、自動的に適切な場所を見つける
  • ツールバーに設定されるLabelはアクセシビリティの向上のため、タイトルがVoiceOverの対象として読まれる
struct ContentView: View {
    var body: some View {
        List {
            Text("Book List")
        }
        .toolbar {
            Button(action: recordProgress) {
                Label("Record Progress", systemImage: "book.circle")
            }
        }
    }

    private func recordProgress() {}
}

// 確認とキャンセルのモーダルアクション  サンプル
struct ContentView: View {
    var body: some View {
        Form {
            Slider(value: .constant(0.39))
        }
        .toolbar {
            ToolbarItem(placement: .confirmationAction) {
                Button("Save", action: saveProgress)
            }
            ToolbarItem(placement: .cancellationAction) {
                Button("Cancel", action: dismissSheet)
            }
        }
    }

    private func saveProgress() {}
    private func dismissSheet() {}
}

// アプリでアイテムを目立たせるためのプリンシパル配置のサンプル
struct ContentView: View {
    enum ViewMode {
        case details
        case notes
    }

    @State private var viewMode: ViewMode = .details

    var body: some View {
        List {
            Text("Book Detail")
        }
        .toolbar {
            ToolbarItem(placement: .principal) {
                Picker("View", selection: $viewMode) {
                    Text("Details").tag(ViewMode.details)
                    Text("Notes").tag(ViewMode.notes)
                }
            }
        }
    }
}

キーボードショートカットモディファイア

  • iPadやMac向けにキーボードショートカットを追加する
@main
struct BookClubApp: App {
    var body: some Scene {
        WindowGroup {
            List {
                Text("Reading List Viewer")
            }
        }
        .commands {
            Button("Previous Book", action: selectPrevious)
                .keyboardShortcut("[")
            Button("Next Book", action: selectNext)
                .keyboardShortcut("]")
        }
    }

    private func selectPreviousBook() {}
    private func selectNextBook() {}
}

プログレスビュー

  • 時間経過と共に確定と不確定の進行状況を表示するのに使用する
  • 線形と円形のプログレスビューがある
// 線形プログレス
struct ContentView: View {
    var percentComplete: Double

    var body: some View {
        ProgressView("Downloading Photo", value: percentComplete)
    }
}

// 円形プログレス
struct ContentView: View {
    var percentComplete: Double

    var body: some View {
        ProgressView("Downloading Photo", value: percentComplete)
            .progressViewStyle(CircularProgressViewStyle())
    }
}

ゲージ

  • プログレスビューと似ているが、全体的な容量と比較し値のレベルを表すのに使用する
  • Delegateによりカスタマイズ可能で、現在値の取得などが可能

struct ContentView: View {
    var acidity: Double

    var body: some View {
        Gauge(value: acidity, in: 3...10) {
            Label("Soil Acidity", systemImage: "drop.fill")
                .foregroundColor(.green)
        } currentValueLabel: {
            Text("\(acidity, specifier: "%.1f")")
        } minimumValueLabel: {
            Text("3")
        } maximumValueLabel: {
            Text("10")
        }
    }
}

スタイリングと視覚効果

  • macOS Big Surでメニューバーの通知センターとコントロールセンターが刷新された
    • 両方ともSwiftUIで実装されており、スタイルやアニメーションが適用されている

matchedGeometryEffect

  • GridやStackなどから流れるようなエフェクトを適用する
  • SwiftUIが自動的にフレーム補間し、シームレスに遷移する
struct ContentView: View {
    @Namespace private var namespace
    @State private var selectedAlbumIDs: Set<Album.ID> = []

    var body: some View {
        VStack(spacing: 0) {
            ScrollView {
                albumGrid.padding(.horizontal)
            }

            Divider().zIndex(-1)

            selectedAlbumRow
                .frame(height: AlbumCell.albumSize)
                .padding(.top, 8)
        }
        .buttonStyle(PlainButtonStyle())
    }

    private var albumGrid: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: AlbumCell.albumSize))], spacing: 8) {
           ForEach(unselectedAlbums) { album in
              Button(action: { select(album) }) {
                 AlbumCell(album)
              }
              .matchedGeometryEffect(id: album.id, in: namespace)
           }
        }
    }

    private var selectedAlbumRow: some View {
        HStack {
            ForEach(selectedAlbums) { album in
                AlbumCell(album)
                .matchedGeometryEffect(id: album.id, in: namespace)
            }
        }
    }

    private var unselectedAlbums: [Album] {
        Album.allAlbums.filter { !selectedAlbumIDs.contains($0.id) }
    }
    private var selectedAlbums: [Album] {
        Album.allAlbums.filter { selectedAlbumIDs.contains($0.id) }
    }

    private func select(_ album: Album) {
        withAnimation(.spring(response: 0.5)) {
            _ = selectedAlbumIDs.insert(album.id)
        }
    }
}

struct AlbumCell: View {
    static let albumSize: CGFloat = 100

    var album: Album

    init(_ album: Album) {
        self.album = album
    }

    var body: some View {
        album.image
            .frame(width: AlbumCell.albumSize, height: AlbumCell.albumSize)
            .background(Color.pink)
            .cornerRadius(6.0)
    }
}

struct Album: Identifiable {
    static let allAlbums: [Album] = [
        .init(name: "Sample", image: Image(systemName: "music.note")),
        .init(name: "Sample 2", image: Image(systemName: "music.note.list")),
        .init(name: "Sample 3", image: Image(systemName: "music.quarternote.3")),
        .init(name: "Sample 4", image: Image(systemName: "music.mic")),
        .init(name: "Sample 5", image: Image(systemName: "music.note.house")),
        .init(name: "Sample 6", image: Image(systemName: "tv.music.note"))
    ]

    var name: String
    var image: Image

    var id: String { name }
}

Container Relative Shape

  • 隣接するシェイプと類似した、パスをたどる新しい機能
  • オフセットを考慮して自動的に同心性を保つ
struct AlbumWidgetView: View {
    var album: Album

    var body: some View {
        album.image
            .clipShape(ContainerRelativeShape())
            .padding()
    }
}

struct Album {
    var name: String
    var artist: String
    var image: Image
}

Dynamic Type Scaling

  • カスタムフォントはDynamic Typeの変更に自動で対応する
  • アクセシビリティのサイズが大きくなっても、レスポンシブの高いカスタムレイアウトを作ることが可能
struct ContentView: View {
    var album: Album
    @ScaledMetric private var padding: CGFloat = 10

    var body: some View {
        VStack {
            Text(album.name)
                .font(.custom("AvenirNext-Bold", size: 30))

            Text("\(Image(systemName: "music.mic")) \(album.artist)")
                .font(.custom("AvenirNext-Bold", size: 17))

        }
        .padding(padding)
        .background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(Color.purple))
    }
}

struct Album {
    var name: String
    var artist: String
    var image: Image
}

List item tinting

  • リスト要素を個別に色をカスタマイズする
    • ItemやSection全体に適用可能
struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                Label("Menu", systemImage: "list.bullet")

                Label("Favorites", systemImage: "heart")
                    .listItemTint(.red)

                Label("Rewards", systemImage: "seal")
                    .listItemTint(.purple)

                Section(header: Text("Recipes")) {
                    ForEach(1..<4) {
                        Label("Recipes \($0)", systemImage: "book.closed")
                    }
                }
                .listItemTint(.monochrome)
            }
            .listStyle(SidebarListStyle())
        }
    }
}

SwiftUIアプリのシステムの統合

  • URLを開く高機能APIが全てのプラットフォームで利用可能となった
    • 例えば、Link ViewでURLを開きリンクをラベル付けする
    • ユニバーサルリンクから他のアプリに遷移することも可能
    • openURL オプションでcompletion handlerでURLを開くことが可能
      • 特定のViewなので自動的にSwiftUIがウィンドウ上にURLを開く
  • iPad OS 13の更新でSwiftUIはドラッグ&ドロップによる他アプリとのやり取りが可能となった
    • Uniform Type Identifiersを使用することによりiPad OS 14からドラッグされるコンテンツの識別子が把握可能となった
    • アプリ全体で適用され、カスタムエクスポート・インポートやイントロスペクション(オブジェクトの情報を参照する)に使用でき、記述を読みやすくしたり、適合性を検証することも可能
let appleURL = URL(string: "https://developer.apple.com/tutorials/swiftui/")!
let wwdcAnnouncementURL = URL(string: "https://apple.news/AjriX1CWUT-OfjXu_R4QsnA")!

struct ContentView: View {
    var body: some View {
        Form {
            Section {
                // リンクタップ後、Defaultのブラウザで開く
                Link(destination: apple) {
                    Label("SwiftUI Tutorials", systemImage: "swift")
                }
                // リンクタップ後、ニュースアプリに遷移
                Link(destination: wwdcAnnouncementURL) {
                    Label("WWDC 2020 Announcement", systemImage: "chevron.left.slash.chevron.right")
                }
            }
        }
    }
}


// openURLオプション
let customPublisher = NotificationCenter.default.publisher(for: .init("CustomURLRequestNotification"))
let apple = URL(string: "https://developer.apple.com/tutorials/swiftui/")!

struct ContentView: View {
    @Environment(\.openURL) private var openURL

    var body: some View {
        Text("OpenURL Environment Action")
            .onReceive(customPublisher) { output in
                if output.userInfo!["shouldOpenURL"] as! Bool {
                    openURL(apple)
                }
            }
    }
}

import UniformTypeIdentifiers

extension UTType {
    static let myFileFormat = UTType(exportedAs: "com.example.myfileformat")
}

func introspecContentType(_ fileURL: URL) throws {
    // Get this file's content type.
    let resourceValues = try fileURL.resourceValues(forKeys: [.contentTypeKey])
    if let type = resourceValues.contentType {
        // Get the human presentable description of the type.
        let description = type.localizedDescription

        if type.conforms(to: .myFileFormat) {
            // The file is our app’s format.
        } else if type.conforms(to: .image) {
            // The file is an image.
        }
    }
}s
ウィジェットでのリンク

Sign in with Apple

  • AuthenticationServicesとSwiftUIをimportすることで新しいAPIが使用可能
  • 全てのプラットフォームで使用可能
import AuthenticationServices
import SwiftUI

struct ContentView: View {
    var body: some View {
        SignInWithAppleButton(
            .signUp,
            onRequest: handleRequest,
            onCompletion: handleCompletion
        )
        .signInWithAppleButtonStyle(.black)
    }

    private func handleRequest(request: ASAuthorizationAppleIDRequest) {}
    private func handleCompletion(result: Result<ASAuthorization, Error>) {}
}
最新情報をチェックしよう!