91
57

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

「iOS 17」SwiftUIの新たな19本の機能とビュー(コード例付き)(WWDC 2023)

Last updated at Posted at 2023-06-16

目次

  • スクロールビューで特定の位置までスクロール
  • App Storeで購入可能なアイテムを表示
  • App Storeのサブスクリプションを表示
  • 写真がNSFWかどうかを分析
  • データストレージSwiftDataを使用
  • SwiftUIのビュースタイルのためにMetalシェーダーを使用
  • マップにマーカーやその他のコンポーネントを追加
  • SFシンボルの画像エフェクト(パルス、反転、バウンス、スケール、表示/非表示、トランジション)
  • 回転のジェスチャー
  • インスペクタビュー(右側に表示されるサイドバー)
  • 新しい#previewプレビューブロック
  • foregroundStyleスタイルの使用
  • TipKitを使ったヒントの表示
  • Swift Macro

はじめに

この記事の多くの部分(例えば、SwiftData、センシティブコンテンツ分析など)については、後ほどQiitaの記事でより詳しい解説を書く予定です。

※一般公開されている(一般に開示した情報)WWDC Keynoteの動画と公開Session/Documentationページだけを使ってこの記事を執筆しました。スクリーンショットはWWDCのセッション映像のものを使用しています。Xcodeのベータ版にアクセスできる場合は、自身でコードを実行されることをお勧めします。

ScrollView内で特定の位置までスクロールする

ScrollViewとその内部のスタック構造を使用している場合、
特定の行までビューをスクロールすることができます。

まず、.scrollTargetLayout()というビューモディファイアを、ScrollView内に主要な繰り返しコンテンツを含むレイアウトコンテナに追加します。これはVStack、LazyHStack、HStack、またはForEachなどになります。

import SwiftUI

struct ScrollViewToRow: View {
    var body: some View {
        VStack {
            ScrollView {
                ForEach(1..<30, id: \.self) { number in
                    // ...
                }
+                .scrollTargetLayout()
            }
        }
    }
}

この例では、1から30までのすべての数字をループするためにForEachを使用しています。したがって、このビューモディファイアをForEachに追加します。

次に、scrollPositionビューモディファイアを使用してスクロール位置をバインドします。このビューモディファイアはScrollViewに添付されます。

import SwiftUI

struct ScrollViewToRow: View {
+    @State private var scrollPosition: Int? = 0
    var body: some View {
        VStack {
            ScrollView {
                ForEach(1..<30, id: \.self) { number in
                    // ...
                }
                    .scrollTargetLayout()
            }
+            .scrollPosition(id: $scrollPosition)
        }
    }
}

オプションのInt型の@State変数を使用します。

新しい値を割り当てることでスクロール位置を更新することができます。コードをwithAnimationブロック内に配置すると、スクロールアニメーションが表示されます。

現在のスクロール位置を読み取ることもできます。

import SwiftUI

struct ScrollViewToRow: View {
    
    @State private var scrollPosition: Int? = 0
    
    var body: some View {
        
        VStack {
            
            Text("currently at \(scrollPosition ?? -1)")
            
            Button("Scroll") {
                withAnimation {
                    scrollPosition = 10
                }
            }
            
            ScrollView {
                ForEach(1..<30, id: \.self) { number in
                    HStack {
                        Text(verbatim: number.formatted())
                        Spacer()
                    }
                    .padding()
                    .foregroundStyle(.white)
                    .background {
                        RoundedRectangle(cornerRadius: 10)
                            .foregroundStyle(.teal)
                    }
                }
                    .scrollTargetLayout()
            }
            .scrollPosition(id: $scrollPosition)
            
        }
        .padding()
        
    }
    
}

#Preview {
    ScrollViewToRow()
}

App Storeのアプリ内購入製品を表示

IMG_1155.JPG

このビューでは、1つの製品を表示しています。
複数の商品を紹介するスクロールビューや水平スタックなどを作成することができます。

この話題については後で詳しくQiitaの記事を書きます

App Storeから取得した製品IDを使用して、ProductViewを初期化できます。

大きな製品ビュー(上記のBox of Nutrition Pelletsのような)を表示するためには、ビューモディファイア .productViewStyle(.large) を使用します。

ProductView(id: ids.nutritionPelletBox) {
    BoxOfNutritionPelletsIcon()
}
.productViewStyle(.large)

ProductViewのコードブロック内では、製品のアイコン(たとえば、Image)を提供できます。

App Storeのすべてのサブスクリプションを表示

Appleから提供されるプレスタイルのビューを使用して、App Storeのサブスクリプションを表示することができます。

これを最も簡単に表示する方法は、SubscriptionStoreView(groupID: birdPassGroupID)を呼び出すだけで、非常にシンプルなビューが表示されます。

IMG_1156.JPG

この話題については後で詳しくQiitaの記事を書きます

このビューはカスタマイズ可能で、カスタムヘッダー、背景、アイコンを表示できます。

まず、サブスクリプショングループIDを使用してSubscriptionStoreViewを初期化します。visibleRelationshipsでは、アップグレードのみを表示するか、すべてのオプション(ダウングレードオプションを含む)を表示するかを定義できます。

ブロック内では、PassMarketingContent(オプション)がヘッダーとして表示されます。ここでは、アプリのアイコン、サブスクリプショングループ名、サブスクリプションの利点を簡単に説明できます。

また、.subscriptionStoreControlIcon(オプション)ビューモディファイアを使用して、各サブスクリプションアイテムのアイコンを定義することもできます。

SubscriptionStoreView(
    groupID: passGroupID,
    visibleRelationships: showPremiumUpgrade ? .upgrade : .all
) {
    PassMarketingContent(showPremiumUpgrade: showPremiumUpgrade)
#if !os(watchOS)
        .containerBackground(for: .subscriptionStoreFullHeight) {
            SkyBackground()
        }
#endif
}
#if os(iOS)
.storeButton(.visible, for: .redeemCode)
#else
.frame(width: 400, height: 550)
#endif
.subscriptionStoreControlIcon { _, subscriptionInfo in
    Group {
        switch PassStatus(levelOfService: subscriptionInfo.groupLevel) {
            case .premium:
                Image(systemName: "bird")
            case .family:
                Image(systemName: "person.3.sequence")
            default:
                Image(systemName: "wallet.pass")
        }
    }
    .foregroundStyle(.accent)
    .symbolVariant(.fill)
}
#if !os(watchOS)
.backgroundStyle(.clear)
.subscriptionStoreButtonLabel(.multiline)
.subscriptionStorePickerItemBackground(.thinMaterial)
#endif

SensitiveContentAnalysisを使用して画像を解析

この話題については後で詳しくQiitaの記事を書きます

rendered2x-1683741577.png

まず、センシティブコンテンツ(NSFW)アナライザーのエンタイトルメントをプロジェクトに追加する必要があります。

イメージアナライザーを初期化します:

let analyzer = SCSensitivityAnalyzer()

次に、アプリは、ユーザーがセンシティブコンテンツ分析機能をオンにしているかどうかをチェックします。

ユーザーは、システム設定(プライバシーセクション内)でこの機能をオンまたはオフにすることができます。この設定がオフの場合、画像の解析はできません。

switch analyzer.analysisPolicy {
    case .simpleInterventions:
        Label("Simple UI", systemImage: "checkmark")
        Text("Blur the image, and show a button to show the content.")
    case .descriptiveInterventions:
        Label("Detailed UI", systemImage: "checkmark")
        Text("Show a detailed description on how these types of images might affect the user.")
    case .disabled:
        Label("Not enabled", systemImage: "xmark")
        Text("To analyze a photo, turn on the sensitive photo warning first in system settings.")
}

その後、CGImageオブジェクトに変換することで、画像を解析することができます

func analyzePhoto(input: CGImage) {
    Task {
        let response = try await analyzer.analyzeImage(input)
        self.isImageSensitive = response.isSensitive
    }
}

以下は、フォトピッカーも含むSwiftUIのコードです。

import SwiftUI
import SensitiveContentAnalysis
import PhotosUI

struct ContentView: View {
    
    let analyzer = SCSensitivityAnalyzer()
    
    @State private var pickedPhotoToAnalyze: PhotosPickerItem?
    @State private var analyzedImage: UIImage?
    @State private var isImageSensitive: Bool?
    
    var body: some View {
        
        Form {
            
            Section("Status") {
                
                VStack(alignment: .leading) {
                    Text("Warning enabled in system settings")
                        .font(.headline)
                    switch analyzer.analysisPolicy {
                        case .simpleInterventions:
                            Label("Simple UI", systemImage: "checkmark")
                            Text("Blur the image, and show a button to show the content.")
                        case .descriptiveInterventions:
                            Label("Detailed UI", systemImage: "checkmark")
                            Text("Show a detailed description on how these types of images might affect the user.")
                        case .disabled:
                            Label("Not enabled", systemImage: "xmark")
                            Text("To analyze a photo, turn on the sensitive photo warning first in system settings.")
                    }
                }
                
            }
            .id(analyzer.analysisPolicy.rawValue)
            
            Section("Image analysis") {
                
                PhotosPicker("Pick a photo to analyze", selection: $pickedPhotoToAnalyze)
                    .onChange(of: pickedPhotoToAnalyze) { newValue in
                        if let newValue {
                            handlePickedImageItem(newValue)
                        }
                    }
                
                if let isImageSensitive {
                    if isImageSensitive {
                        Label("Oh no! What image did you pick?", systemImage: "eye.trianglebadge.exclamationmark")
                            .foregroundStyle(.red)
                    } else {
                        Label("It is OK!", systemImage: "checkmark")
                            .foregroundStyle(.green)
                    }
                }
                
                if let analyzedImage,
                   let isImageSensitive
                {
                    if isImageSensitive {
                        Image(uiImage: analyzedImage)
                            .resizable()
                            .scaledToFit()
                            .frame(height: 230)
                            .blur(radius: 5.0, opaque: true)
                    } else {
                        Image(uiImage: analyzedImage)
                            .resizable()
                            .scaledToFit()
                            .frame(height: 230)
                    }
                }
                
            }
            .disabled(analyzer.analysisPolicy == .disabled)
            
        }
        
    }
    
    func analyzePhoto(input: CGImage) {
        Task {
            let response = try await analyzer.analyzeImage(input)
            self.isImageSensitive = response.isSensitive
        }
    }
    
    func handlePickedImageItem(_ newValue: PhotosPickerItem) {
        self.isImageSensitive = nil
        self.analyzedImage = nil
        newValue.loadTransferable(type: Data.self) { result in
            switch result {
                case .success(let success):
                    if let success,
                       let imageObj = UIImage(data: success),
                       let cgImageObj = imageObj.cgImage
                    {
                        self.analyzedImage = imageObj
                        self.analyzePhoto(input: cgImageObj)
                    }
                case .failure(let failure):
                    return
            }
        }
    }
    
}

#Preview {
    ContentView()
}

SwiftData

この話題については後で詳しくQiitaの記事を書きます

SwiftDataはCore Dataを基盤に構築されています(ただし、Core Dataと組み合わせて使用する場合は、ハッシュをチェックするための追加の手順が必要です)。

以下の53行のコードで、保存されるデータの構造を定義し、コンテキストを作成し、新しいレコードを追加するためのSwiftUIビュー、レコードを表示し、削除するためのビューを作成しています。

import SwiftUI
import SwiftData

@Model
class Record {
    
    var id: UUID
    var timestamp: Date
    
    init(timestamp: Date) {
        self.id = UUID()
        self.timestamp = timestamp
    }
    
}

struct SwiftDataDemo: View {
    var body: some View {
        RecordsList()
            .modelContainer(for: [Record.self])
    }
}

struct RecordsList: View {
    @Environment(\.modelContext) private var modelContext
    
    @Query(sort: \.timestamp, order: .forward, animation: .snappy)
    var storedRecords: [Record]
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(storedRecords) { record in
                    Text(record.timestamp, format: .dateTime)
                }
                .onDelete(perform: { indexSet in
                    indexSet.forEach({ modelContext.delete(storedRecords[$0]) })
                })
            }
            .toolbar {
                Button ("Add") {
                    let location = Record(timestamp: Date())
                    modelContext.insert(location)
                }
            }
        }
    }
}

#Preview {
    SwiftDataDemo()
}

上記のコードにおいて、以下のコードでデータ構造を定義しています:

@Model
class Record {
    
    var id: UUID
    var timestamp: Date
    
    init(timestamp: Date) {
        self.id = UUID()
        self.timestamp = timestamp
    }
    
}

ビューにストレージをアタッチします。まだ存在しない場合は、新しいストレージが作成されます。

struct SwiftDataDemo: View {
    var body: some View {
        RecordsList()
            .modelContainer(for: [Record.self])
    }
}

上記のビューモディファイアには、さらにinitパラメータがあり、例えば、データを自動的に保存するかどうか、完了時にコードを実行するかどうかなどを制御することができます。

また、iCloudを有効にし、iCloudコンテナを作成することも可能です。データはiCloudに同期されます

データのクエリには @Query を使用することができます

@Query(sort: \.timestamp, order: .forward, animation: .snappy)
var storedRecords: [Record]

レコードを削除するには、データベースを読む@Environmental変数を作成します。

@Environment(\.modelContext) private var modelContext

デフォルトでは、変更内容は自動的に保存されます。

ShaderLibraryを使用して、ビュースタイルにMetalを適用

Fx82SziaEAE-Fpy.jpeg

まず、シェーダーの形状を定義する.metal定義ファイルを作成します。

//  angledFill.metal

#include <metal_stdlib>
using namespace metal;

[[ stitchable ]] half4
angledFill(float2 position, float width, float angle, half4 color)
{
    float pMagnitude = sqrt(position.x * position.x + position.y * position.y);
    float pAngle = angle +
    (position.x == 0.0f ? (M_PI_F / 2.0f) : atan(position.y / position.x));
    float rotatedX = pMagnitude * cos(pAngle);
    float rotatedY = pMagnitude * sin(pAngle);
    return (color + color * fmod(abs(rotatedX + rotatedY), width) / width) / 2;
}

次に、上記の.metal定義内の関数(angleFillと呼ばれる)を参照する新しいShaderシェーダーを作成します。

var stripes: Shader {
    ShaderLibrary.angledFill(
        .float(10),
        .float(90),
        .color(.blue)
    )
}

これで、.foregroundStyle ビューモディファイアを使って、テキストにShaderシェーダースタイルを適用できるようになりました。

Text("Furdinand").font(.system(size: 50).bold()).foregroundStyle(stripes) + Text(" is a good dog.").font(.system(size: 50).bold())

SwiftUIで異なるテキストコンポーネントを連結させることができます。

.foregroundStyleについて、

.foregroundStyleビューモディファイアを使用して、
SwiftUIビューに色またはMetalスタイルを設定することができます。

このビューモディファイアはiOS 15から使用可能ですが、iOS 17では入力パラメータとしてシェーダーを使用することができます。

>= iOS 15

iOS 15以降、.foregroundStyleを使用してビューの色やグラデーションを設定することができます。

Text("Furdinand")
    .font(.system(size: 50).bold())
    .foregroundStyle(.blue)
Text("Hello world")
    .foregroundStyle(.linearGradient(colors: [.teal, .blue], startPoint: .top, endPoint: .bottom))

.foregroundColor`は非推奨 (will be deprecated) になるようです。

スクリーンショット 2023-06-17 15.19.27.png

もしiOS 14以前のデバイス向けのアプリをターゲットにしている場合、カスタムのビューモディファイアを作成することができます。一方、iOS 15以降のデバイス向けのアプリをターゲットにしている場合は、新しいforegroundStyleビューモディファイアにコードを置き換えることができます。

カスタムのビューモディファイア: https://gist.github.com/mszpro/fb6bb8a95376402daf433220de222389

>= iOS 17

iOS 17では入力パラメータとしてシェーダーを使用することができます。

var stripes: Shader {
    ShaderLibrary.angledFill(
        .float(10),
        .float(90),
        .color(.blue)
    )
}

Text("Furdinand")
    .font(.system(size: 50).bold())
    .foregroundStyle(stripes)

SwiftUIでMapKitのマップを制御

以下は、WWDCのセッション動画からのスクリーンショットです。

Markerや図形(円、折れ線、多角形)を簡単に適用することができます。
また、@Binding変数を使用することで、現在どのMarkerが選択されているかを確認することができます。

スクリーンショット 2023-06-16 12.01.12.png

スクリーンショット 2023-06-16 12.01.19.png

スクリーンショット 2023-06-16 12.01.52.png

この話題については後で詳しくQiitaの記事を書きます

SFシンボルエフェクト

SwiftUIでSFシンボル画像に多くの視覚効果を加えることができる

パルス

シンボルの不透明度をアニメーションで表示します。

Image(systemName: "rectangle.inset.filled.and.person.filled")
            .symbolEffect(.pulse)
            .frame(maxWidth: .infinity)
            .font(.system(size: 50))
            .symbolRenderingMode(.multicolor)

リバーシング

シンボルをレイヤーごとにアニメーションさせることができます。例えば、WiFi接続のアニメーションを作成することができます。

Image(systemName: "wifi")
    .symbolEffect(.variableColor.iterative.reversing)
    .font(.system(size: 50))
    .symbolRenderingMode(.multicolor)

バウンス(ハートビート効果)

画像のスケールをアニメーションで変化させます。画像を拡大し、縮小します。心臓の鼓動のようです。

Image(systemName: "arrow.down.circle")
    .symbolEffect(.bounce, value: simulatedDownloadPercentage)
    .font(.system(size: 50))
    .symbolRenderingMode(.multicolor)

拡大・縮小

上記と同様に、bool値を使ってシンボルを拡大・縮小することもできます。以下のサンプルコードでは、simulatedDownloadPercentage が偶数である場合に画像を拡大表示しています。

Image(systemName: "bubble.left.and.bubble.right.fill")
    .symbolEffect(.scale.up, isActive: simulatedDownloadPercentage % 2 == 0)
    .font(.system(size: 50))
    .symbolRenderingMode(.multicolor)

表示・非表示

Image(systemName: "cloud.sun.rain.fill")
    .symbolEffect(.disappear, isActive: simulatedDownloadPercentage % 2 == 0)
    .font(.system(size: 50))
    .symbolRenderingMode(.multicolor)

2つのシンボル間のトランジション効果

1つのシンボルから別のシンボルへの切り替え時に、トランジション効果を追加することができます。例えば、再生ボタンで、再生アイコンと一時停止アイコンを切り替えるような場合に使用できます。

VStack {
    Image(systemName: runToggle ? "play.fill" : "pause.fill")
        .contentTransition(.symbolEffect(.replace.downUp))
        .font(.system(size: 50))
        .symbolRenderingMode(.multicolor)
    Button("Toggle status") {
        runToggle.toggle()
    }
}

回転ジェスチャー

回転ジェスチャーを認識することで、ビューの回転を監視することができます。

struct RotationTesting: View {
    @State private var angleRotated = Angle(degrees: 0.0)

    var body: some View {
        Rectangle()
            .frame(width: 200, height: 200, alignment: .center)
            .rotationEffect(angleRotated)
            .gesture(
                RotateGesture()
                    .onChanged { value in
                        angleRotated = value.rotation
                    }
            )
    }
}

インスペクタービュー(右側のサイドバー)を表示

インスペクターは右側に表示され、通常、ユーザーがアイテムの詳細を表示するために使用されます。

inspector.jpg

public struct ContentView: View {
    @State private var state = AppState()
    @State private var presented = true

    public var body: some View {
        AnimalTable(state: $state)
            .inspector(isPresented: $presented) {
                AnimalInspectorForm(animal: $state.binding())
                    .inspectorColumnWidth(
                        min: 200, ideal: 300, max: 400)
                    .toolbar {
                        Spacer()
                        Button {
                            presented.toggle()
                        } label: {
                            Label("Toggle Inspector", systemImage: "info.circle")
                        }
                    }
            }
    }
}

簡単にプレビュー

SwiftUIのビューをプレビューするために#previewを記述することができます。
また、#preview内で変数を直接追加することもできます。

#Preview {
    ContentView()
}

TipKitを使ったユーザーへのチップ表示について

TipKitを使って、ユーザーにヒントを表示し、どこにどんな機能があるかを理解させることができます。

iOSベータ1では、ヒントを表示する際に問題があるかもしれませんが、
後でコードを更新します。

Fx53LuLakAENWgH.jpeg

Fx9t6XFX0AElPzo.jpeg

Macros


お読みいただき、ありがとうございました。

:relaxed: Twitter @MszPro

:relaxed: 個人ウェブサイト https://MszPro.com


上記内容の一部は、Apple社のサンプルコードから引用しています。ライセンスは下記に添付しています:

Copyright © 2023 Apple Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
91
57
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
91
57

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?