5
1

More than 1 year has passed since last update.

WWDC2022の面白かった話をまとめてみた

Last updated at Posted at 2022-12-07

初めに

WWDCの動画を視聴し、内容についてまとめていきます。

内容

- Meet Swift Async Algorithms

前提知識として、ConcurrencyAsyncSequence を用いることで、非同期の値に対しても Sequence のように for in ループを使うことができました。(らしいです)

以下の例では公式ドキュメントで紹介されているカスタム Counter を使用しています。AsyncSequence を使用する場合は makeAsyncIterator() の実装が必要で、AsyncIteratorProtocol を使用する場合は next() の実装が必要です。

.swift
for await i in Counter(howHigh: 10) {
    print(i, terminator: " ")
}
// Prints: 1 2 3 4 5 6 7 8 9 10

また、map, filter, reduceなどの関数も提供されていました。

.swift
let stream = Counter(howHigh: 10)
    .map { $0 % 2 == 0 ? "Even" : "Odd" }

2022年3月に Apple が swift-async-algorithms の Beta 版をリリースしました。(2022年7月時点)
SPM でのダウンロードが可能で、AsyncSequence を拡張した機能などが提供されています。

.swift
for try await (video, preview) in zip(videos, previews) {
    try await upload(video, preview)
}
.swift
for try await message in merge(primaryAccount.messages, secondaryAccount.messages) {
    displayPreview(message)
}

他にもたくさんの機能がありますが、インクリメンタルサーチや??秒ごとに送信などの実装が簡単にできるようになります。

インクリメンタルサーチの例

.swift
let channel = AsyncChannel<SearchResult>()
for await query in queryValue.debounce(for: .milliseconds(300)) {
    let results = try await performSearch(query)
    channel.send(results)
}
.swift
// 0.5秒ごとに送信する
let batches = outboundMessages.chunked(
    by: .repeating(every: .milliseconds(500))
)
let encoder = JSONEncoder()
for await batch in batches {
    let data = try encoder,encode(batch)
    try await postToServer(data)
}

- Meet Swift Regex

ついにSwiftに正規表現がやってきました。文字列操作の幅が広がり、容易になるでしょう。
(競プロでも使えるかな?)

動画で紹介されていた例を紹介します。以下のような台帳から要素を一つずつ取り出したい場合を考えます。

.swift
let ledgers = """
KIND    DATE        INSITUTION              AMOUNT
----------------------------------------------------------
CREDIT  03/02/2022  Payroll from employer   $200.23
CREDIT  03/03/2022  Suspect A               $2,000,000.00
DEBIT   06/03/2022  Doug's Dugout Dogs      £57.33
"""

データの1行目だけに着目すると、以下のようなコードでデータ要素を取り出せます。

.swift
import RegexBuilder
import Foundation

let ledger = "CREDIT  03/02/2022  payroll from employer   $200.23"

let fieldSeparator = /\s{2,}|\t/ // 2つ以上のスペースもしくはタブ文字
let transactionMatcher = Regex {
    Capture { /CREDIT|DEBIT/ }
    fieldSeparator
    Capture { One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: TimeZone(secondsFromGMT: 0)!)) }
    fieldSeparator
    Capture {
        OneOrMore {
            CharacterClass.any
        }
    }
    fieldSeparator
    Capture { One(.localizedCurrency(code: "USD").locale(Locale(identifier: "en_US"))) }
}
// transactionMatcher: Regex<(Substring, Substring, Date, Substring, Decimal)>

if let match = ledger.firstMatch(of: transactionMatcher) {
    let (wholeMatch, kind, date, institution, amount) = match.output
    print(wholeMatch)  // CREDIT  03/02/2022  payroll from employer   $200.23
    print(kind)        // CREDIT
    print(date)        // 2022-03-02 00:00:00 +0000
    print(institution) // payroll from employer
    print(amount)      // 200.23
}

RegexBuilderimport することで SwiftUIViewBuilder のように書けます。
Capture { ... } の部分が output の型として定義されるといった感じです。(結構大変ですね...)

- Swift Regex: Beyond the basics

Regex の初期化には3種類があります。

.swift
let input = "name:  John Appleseed,  user_id:    100"

// `user_id`を抽出する例
let regex1 = try Regex(#"user_id:\s*(\d+)"#)
let regex2 = /user_id:\s*(\d+)/
let regex3 = Regex {
    "user_id:"
    OneOrMore(.whitespace)
    Capture(.localizedInteger(locale: .init(identifier: "en_US")))
}

他にもいろいろな関数があるヨ

.swift
let input = "name:  John Appleseed,  user_id:    100"
let regex = /user_id:\s*(\d+)/

input.firstMatch(of: regex)         // Regex.Match<(Substring, Substring)>
input.wholeMatch(of: regex)         // nil
input.prefixMatch(of: regex)        // nil

input.starts(with: regex)           // false
input.replacing(regex, with: "456") // "name:  John Appleseed,  456"
input.trimmingPrefix(regex)         // "name:  John Appleseed,  user_id:    100"
input.split(separator: /\s*,\s*/)   // ["name:  John Appleseed", "user_id:    100"]

また、Capturetransform クロージャを使うと Substring 以外の戻り値を返すことも可能です。

.swift
let input = "My answer is fuga."

enum MetaVars: String {
    case hoge
    case fuga
    case piyo
}

let regex = Regex {
    OneOrMore(.any)
    TryCapture {
        ChoiceOf {
            "hoge"
            "fuga"
            "piyo"
        }
    } transform: {
        MetaVars(rawValue: String($0))
    }
    Optionally(".")
}

if let match = try? regex.firstMatch(in: input) {
    let (wholeMatch, metaVar) = match.output
    print(wholeMatch)        // My answer is fuga.
    print(metaVar)           // fuga
    print(type(of: metaVar)) // MetaVars
}

- Embrace Swift generics

そろそろ記事を書くのも読むのも疲れてきたので筋トレでもしましょう。まずはトレーニング種目の定義から...

.swift
protocol Big3 {}

struct Squat: Big3 {}
struct Deadlift: Big3 {}
struct BenchPress: Big3 {}

Big3 は欠かせませんから、トレーニーを定義するなら既存の generics を使って以下の様になるでしょう。

.swift
struct Trainee {
    func workout<Menu>(_ menu: Menu) where Menu: Big3 { ... }
    // もしくは
    func workout<Menu: Big3>(_ menu: Menu) { ... }
}

Opaque Result Type を使うと、上の例がシンプルに書けます。

.swift
struct Trainee {
    func workout(_ menu: some Big3) { ... }
}

次に、脳筋とそうない人のメニューを比較してみます。

.swift
let noukinMenus: [some Big3] = [Squat(), Squat(), Squat()]
let smartMenus: [any Big3] = [Squat(), Deadlift(), BenchPress()]

脳筋は(たぶん)スクワットしかやらないので [some Big3] で問題ないですが、賢い人はさまざまなメニューを取り入れているため [any Big3] とする必要があります。

some は「どれかひとつ」、any は「どれでも」というイメージです。そのため、some Big3 に複数の型を代入しようとするとエラーになります。

.swift
var menu: some Big3 = Squat()
menu = Deadlift() // エラー
let smartMenus: [some Big3] = [Squat(), Deadlift(), BenchPress()] // エラー

また、動画ではデフォルトでは some を使いましょうという注意がありました。

- The SwiftUI cookbook for navigation

まずは Navigation の遷移に関するセッションです。従来の SwiftUI では NavigationLink を使用して遷移先の画面を指定していました。この際、isActiveBinding<Bool> を渡すことで遷移イベントを発火させていました。

.swift
var body: some View {
    NavigationLink(
        "Details",
        isActive: $item.showDetail
    ) { DetailView() }
}

iOS16 からの新しい NavigationLink では、初期化時に path データを渡すことで画面遷移がしやすくなります。

.swift
@State private var path: [Recipe] = []

var body: some View {
    NavigationStack(path: $path) {
        List(Category.allCases) { category in
            Section(category.localizedName) {
                ForEach(dataModel.recipes(in: category)) { recipe in
                    NavigationLink(recipe.name, value: recipe)
                }
            }
        }
        .navigationTitle("Categories")
        .navigationDestination(for: Recipe.self) { recipe in
            RecipeDetails(recipe: recipe)
        }
    }
}

以下のように path 変数を変更することで遷移できるようになります。

.swift
func showRecipeOfTheDay() {
    path = [dataModel.recipeOfTheDay]
}

func popToRoot() {
    path.removeAll()
}

- Compose custom layouts with SwiftUI

Case 1

まずは Grid layout の登場です。下図のように、2つのラベルと ProgressView といった3つの要素からなる3列のレイアウトを考えます。

a9c301da-f240-73da-719c-e8a58e208cc7.png

VStackHStack を組み合わせるてゴリゴリ頑張るのが従来の手法だと思いますが、iOS16 からは以下のようにレイアウトが組めるようになります。

.swift
@State private var pets: [Pet] = Pet.exampleData

var body: some View {
    Grid(alignment: .leading) {
        ForEach(pets) { pet in
            GridRow {
                Text(pet.type)
                ProgressView(
                    value: Double(pet.votes),
                    total: Double(totalVotes)
                )
                Text("\(pet.votes)")
                    .gridColumnAlignment(.trailing)
            }
        }
    }
}

GridGridRow が新しく使えるようになります。

よく見るとただ要素が並んでいるだけでなく、ペット名が左寄せになり、値ラベルが右寄せになっています。VStackHStack では spacing など細かい調整が必要になりそうなので、これは便利ですね!ちなみに、alignment は何も指定しなければ .center になります。

Case 2

次に、下図のような3つの要素を持ったレイアウトを考えます。最も大きい要素に合わせて、全て等しいサイズにしたいといったものです。中身が変化する場合は動的にサイズを変更する必要があるため、従来の SwiftUI ではかなり難しい?というか無理じゃないかと思います。

144e5431-8b45-7f0b-7391-9f051211f67b.png

iOS16 からは Layout プロトコルを使うことでこのレイアウトを実現できます。

.swift
@State private var pets: [Pet] = Pet.exampleData

var body: some View {
    EqualWidthHStack {
        ForEach($pets) { $pet in
            Button {
                pet.votes += 1
            } label: {
                Text(pet.type)
                    .frame(maxWidth: .infinite)
            }
            .buttonStyle(.bordered)
        }
    }
}
.swift
struct EqualWidthHStack: Layout {

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) -> CGSize {
        /* 略 */
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) {
        /* 略 */
    }
}
Layout の詳細な実装コードはこちら
.swift
struct EqualWidthHStack: Layout {
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) -> CGSize {
        let maxSize = maxSize(subviews: subviews)
        let spacing = spacing(subviews: subviews)
        let totalSpacing = spacing.reduce(0) { $0 + $1 }

        return CGSize(
            width: maxSize.width * CGFloat(subviews.count) + totalSpacing,
            height: maxSize.height
        )
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) {
        let maxSize = maxSize(subviews: subviews)
        let spacing = spacing(subviews: subviews)

        let sizeProposal = ProposedViewSize(
            width: maxSize.width,
            height: maxSize.height
        )
        var x = bounds.minX + maxSize.width / 2

        for index in subviews.indices {
            subviews[index].place(
                at: CGPoint(x: x, y: bounds.midY),
                anchor: .center,
                proposal: sizeProposal
            )
            x += maxSize.width + spacing[index]
        }
    }

    private func maxSize(subviews: Subviews) -> CGSize {
        let subviewSizes = subview.map { $0.sizeThatFits(.unspecified) }
        return subviewSizes.reduce(.zero) { currentMax, subviewSize in
            CGSize(
                width: max(currentMax.width, subviewSize.width),
                height: max(currentMax.height, subviewSize.height)
            )
        }
    }

    private func spacing(subviews: Subviews) -> CGSize {
        return subviews.indices.map { index in
            guard index < subviews.count - 1 else { return 0 }
            return subviews[index].spacing?.distance(
                to: subviews[index + 1].spacing,
                along: .horizontal
            )
        }
    }
}

Layout プロトコルには

  • sizeThatFits(proposal:subviews:cache:) : ビューのサイズ計算
  • placeSubviews(in:proposal:subviews:cache:) : 指定の位置にビューを配置

の2つの必須メソッドがあります。サイズ計算や座標計算など面倒なことは増えましたが、その分レイアウトの幅がぐんと広がりそうな予感がします。(ドキュメントはこちら)

- Use SwiftUI with UIKit

UIHostingControllersizingOptions プロパティが追加されました。

.swift
// Presenting UIHostingController as a popover

let heartRateView = HeartRateView()
let hostingController = UIHostingController(rootView: heartRateView)

//Enable automatic preferredContentSize updates on the hosting controller
hostingController.sizingOptions = .preferredContentSize

hostingController.modalPresentationStyle = .popover
self.present(hostingController, animated: true)

まだイマイチ理解できていませんが、SwiftUI のビューが更新されたときに、自動でリサイズしてくれるようになるらしいです。

次は UIHostingConfiguration です。ついに CollectionViewTableView のセルを SwiftUI で書けるようになりました。(ドキュメントはこちら)

.swift
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)

    cell.contentConfiguration = UIHostingConfiguration {
        CustomCell()
    }

    return cell
}

struct CustomCell: View {
    var body: some View {
        Text("custom")
    }
}

今まで通り UIKitCollectionView を使い、セルの部分のみを SwiftUI に切り離せるようになります。

  • Efficiency awaits: Background tasks in SwiftUI

SwiftUI でバックグラウンドからアプリを起動できるようになります。今回の例は毎日正午に天気情報を取得し、嵐だったら写真を撮るようにプッシュ通知を送るというもの。(なぜ嵐なのか)

従来の手法では、バックグラウンドでアプリを動かすためには AppDelegateSceneDelegate に記述する必要がありましたが、これを SwiftUI からできるようになります。

.swift
import SwiftUI
import BackgroundTasks

@main
struct StormyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .backgroundTask(.appRefresh("StormyNoon")) { // New!!
            scheduleAppRefresh()
            // 嵐だったらプッシュ通知を送信する
            if await isStormy() {
                await notifyForPhoto()
            }
        }
    }
}
.swift
// 毎日正午にバックグラウンドでアプリを開くようにする
func scheduleAppRefresh() {
    let today = Calendar.current.startOfDay(for: .now)
    let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)
    let noonComponent = DateComponents(hour: 12)
    let noon = Calendar.current.date(byAdding: noonComponent, to: tomorrow)

    let request = BGAppRefreshTaskRequest(identifier: "StormyNoon")
    request.earliestBeginDate = noon
    try? BGTaskScheduler.shared.submit(request)
}

- What's new in WKWebView

最後は WKWebView の新機能です。動画やキャンバスゲームをフルスクリーンで表示させたい場合、WKPreferencesisElementFullscreenEnabled で可能になりました。(iOS15.4からの機能らしい)

また、iOS16 からは fullscreenState が追加されて、状態の変更を監視できるようになりました。

.swift
// Fullscreen API support
webView.configuration.preferences.isElementFullscreenEnabled = true

webView.loadHTMLString("""
<script>
    button.addEventListener('click', () => {
        canvas.webkitRequestFullscreen()
    }, false);
</script>
...
""", baseURL: nil)

let observation = webView.observe(\.fullscreenState, options: [.new]) { object, change in
    print("fullscreenState: \(object.fullscreenState)")
}

次は、キーワード検索です。Chrome の ⌘+F でできるキーワード検索が WKWebView 内でもできるようになります。

.swift
webView.findInteractionEnabled = true

if let interaction = webView.findInteraction {
    interaction.presentFindNavigator(showingReplace: false)
}

UIFindInteraction を利用し、presentFindNavigator を呼ぶことで検索バーを表示することができます。

- What's new in Xcode

まずは Xcode14 のアップデート内容です。ここは簡潔にまとめます。

  • 起動時間が30%速くなった🤩
  • Preview のキャンバスがデフォルトでインタラクティブになり、すぐに触って動かせる
  • initializer の補完(プロパティを1つずつ定義しなくて済む)
  • ビルド時間が25%速くなった🤩

その他の変更としては、下画像のように initializer で複数の引数がある場合、必要なものだけを選んで残りはデフォルトのままで、ということができるようになります。

bd58e652-476d-a0b4-8375-d8be85d733be.png

また、クラスや関数が長い場合にファイルの上部に追従して表示してくれるようになります。特定クラスのテストだけ動かしたい場合とかにいちいちファイルの先頭まで移動しなくて済むので地味にありがたい...

41d632f9-6fb4-3190-8ca1-d6d36cc1dd1a.png

- Get to know Developer Mode

iOS16 と watchOS 9 の新しいモードとして、Developer Mode というのが追加されます。

Developer Mode, introduced in iOS 16 and watchOS 9, protects people from inadvertently installing potentially harmful software on their devices, and reduces attack vectors exposed by developer-only functionality.
iOS 16とwatchOS 9で導入されたデベロッパーモードは、潜在的に有害なソフトウェアを不用意にデバイスにインストールすることから人々を保護し、開発者限定機能によってさらされる攻撃ベクトルを低減します。

上記の通り、Developer Mode は開発用機能を攻撃から守るためのシステムです。主に、自分のデバイスでローカル開発(実機ビルド)する際に必要になります。

一方で、

  • Test Flight からのダウンロード
  • Enterprise(In-House) distribution
  • App Store からのダウンロード

では必要ありません。

Developer Mode は、「XcodeとiPhoneを接続 -> iPhoneで設定 -> Privacy & Security -> Developer Modeをオンにする」という手順で有効化できます。

- What’s new in iPad app design

最後は iPadOS 16 での新機能の紹介です。

まずはツールバー。レイアウトが変わり、中央セクションの要素をカスタマイズできるようになりました!(画像奥がiPadOS 15、手前がiPadOS 16)

be4b26b4-c139-016f-9109-250f63ee2efe.png

次は、検索と置換です。システムキーボードに検索と置換が内蔵されているため、特定の単語やフレーズを素早く検索できます!

d5a71d10-221c-0dec-6fe5-a7d5e0980012.png

続いて、Appナビゲーションです。「ブラウザスタイル」という新しいスタイルのナビゲーションが追加されます。

例えば、ファイル App では戻る・進むボタンでサイドバーの別の場所にあるフォルダー間を簡単にブラウジングできるようになります。

bcd45632-612d-6492-89d5-2eeb12321d0f.png

次は、選択メニューです。iPadOS 16では、バンドセレクションをしても自動で編集モードになることがなくなります。

03b274b9-d8ae-0e84-3f14-4d2156189eda.png

選択した要素は、クリックすることでまとめて操作できます。

a9c014d4-e84d-bc53-5e28-b3f8ec2191e6.png

続いてサブメニューです。iPhone ではサブメニューは縦向きに開きます。スペースが限られていて、タップしなければいけないこともあり、本当に必要な場合にのみサブメニューを使用することが最適でした。

iPadOS 16 では、サブメニューはスペースがあれば横向きに展開されるようになります。

f71d58f7-0b82-070f-d813-afc77cc3f3ea.png

最後はテーブルです。今までのテーブルは単一の列だけでしたが、iPadOS 16 では複数列で情報を表示できるようになります。さらにヘッダーをタップすることでソートできるようになります。

3ed33cb0-9a24-6ab4-9515-81f7aa704a94.png

終わりに

アップデートたくさんありましたね!(まだまだたくさんある...)
続きがあれば追記したいと思います!

ご指摘などあればmm

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1