SwiftUIでHome Screen Quick Actions に対応してみた

2022.08.17

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

iOS 13以降のデバイスのホーム画面では、アプリのアイコンを長押しすると、ホーム画面のクイックアクションを表示できます(3D Touch デバイスでは、ユーザーはアイコンを短く押す)。

引用: Apple公式: iPhoneでクイックアクションを実行する

このようにホーム画面から簡単なアプリの操作が出来る機能で、楽しそうなので今回はSwiftUIで試してみることにしました。

環境

  • Xcode 13.3

はじめに

Info.plistにクイックアクションを定義することで静的なクイックアクションを定義することが出来ますが、今回はInfo.plistを使用しない方法でクイックアクションを実装しました。

作ったもの

swiftui-home-screen-quick-actions

クイックアクションで選択したアクションによって、開かれたアプリのUIを変更するというシンプルなものになります。

QuickAction

まずはクイックアクションを設定するために列挙型のQuickActionを作成します。

import UIKit

enum QuickAction: String, CaseIterable {
    case flame = "Flame"
    case water = "Water"
    case thunder = "Thunder"

    init?(shortcutItem: UIApplicationShortcutItem?) {

        guard let shortcutItem = shortcutItem,
              let action = QuickAction(rawValue: shortcutItem.type)
        else { return nil }

        self = action
    }

    var imageName: String {
        switch self {
        case .flame:
            return "flame"
        case .water:
            return "drop"
        case .thunder:
            return "bolt"
        }
    }

    var shortcutItem: UIApplicationShortcutItem {
        switch self {
        case .flame:
            return UIMutableApplicationShortcutItem(
                type: self.rawValue,
                localizedTitle: self.rawValue,
                localizedSubtitle: "Viewが熱く燃え上がります",
                icon: UIApplicationShortcutIcon(systemImageName: self.imageName)
            )
        case .water:
            return UIMutableApplicationShortcutItem(
                type: self.rawValue,
                localizedTitle: self.rawValue,
                localizedSubtitle: "Viewが水に満たされます",
                icon: UIApplicationShortcutIcon(systemImageName: self.imageName)
            )
        case .thunder:
            return UIMutableApplicationShortcutItem(
                type: self.rawValue,
                localizedTitle: self.rawValue,
                localizedSubtitle: "Viewが雷光により輝きます",
                icon: UIApplicationShortcutIcon(systemImageName: self.imageName)
            )
        }
    }
}

init

後で記述しますが、クイックアクションからアプリを開いた際にUIApplicationShortcutItemを取得出来ます。そのUIApplicationShortcutItem.typeを使用して選択されたアクションを初期化しています。

init?(shortcutItem: UIApplicationShortcutItem?) {

    guard let shortcutItem = shortcutItem,
          let action = QuickAction(rawValue: shortcutItem.type)
    else { return nil }

    self = action
}

imageName

UIApplicationShortcutItemの画像として使用するSystem Imageの名前になります。

var imageName: String {
    switch self {
    case .flame:
        return "flame"
    case .water:
        return "drop"
    case .thunder:
        return "bolt"
    }
}

shortcutItem

それぞれのケースに該当するUIMutableApplicationShortcutItemを返しています。UIMutableApplicationShortcutItemを初期化することで変更可能な動的クイックアクションを作成することが出来ます。

var shortcutItem: UIApplicationShortcutItem {
    switch self {
    case .flame:
        return UIMutableApplicationShortcutItem(
            type: self.rawValue,
            localizedTitle: self.rawValue,
            localizedSubtitle: "Viewが熱く燃え上がります",
            icon: UIApplicationShortcutIcon(systemImageName: self.imageName)
        )

UIMutableApplicationSHortcutItem

  • type
    • 実行するクイックアクションの種類を識別するために使用する文字列
  • localizedTitle
    • ユーザーに表示されるタイトル
  • localizedSubTitle
    • ユーザーに表示されるサブタイトル
  • icon
    • クイックアクションのアイコン
  • userInfo
    • クイックアクション実行時に提供できるユーザー情報

実際のクイックアクションと照らし合わせるとこのようになります。

QuickActionState

QuickActionの状態を管理するQuickActionStateを作成します。

import UIKit

class QuickActionState: ObservableObject {

    static let shared = QuickActionState()
    private init() {}

    @Published var selectedAction: QuickAction?
    private var isEnteredFromQuickAction = false

    func setActions() {
        let shortcutItems = QuickAction.allCases.map { $0.shortcutItem }
        UIApplication.shared.shortcutItems = shortcutItems
    }

    func selectAction(by shortcutItem: UIApplicationShortcutItem?) {
        let action = QuickAction(shortcutItem: shortcutItem)
        selectedAction = action
        isEnteredFromQuickAction = action == nil ? false : true
    }

    func removeSelectedActionIfNeeded() {
        if isEnteredFromQuickAction {
            isEnteredFromQuickAction = false
            return
        }
        selectedAction = nil
    }
}

プロパティ

selectedAction

選択されたクイックアクションをパブリッシュする為の変数です。

@Published var selectedAction: QuickAction?

isEnteredFromQuickAction

今回はクイックアクションからアプリを開いたかどうかでUIを変更する為にそのフラグを用意しました。

private var isEnteredFromQuickAction = false

ファンクション

setActions

定義したクイックアクションをUIApplication.shared.shortcutItemsに渡す関数です。

func setActions() {
    let shortcutItems = QuickAction.allCases.map { $0.shortcutItem }
    UIApplication.shared.shortcutItems = shortcutItems
}

selectAction

クイックアクションが選択された場合にそのクイックアクションに紐づくUIApplicationShortcutItemが渡ってきます。そのUIApplicationShortcutItemからQuickActionを生成して、selectedActionに渡しています。またQuickActionnilでは無いということは、クイックアクションからアプリを開いたということなので、isEnteredFromQuickActionのフラグを切り替えています。

func selectAction(by shortcutItem: UIApplicationShortcutItem?) {
    let action = QuickAction(shortcutItem: shortcutItem)
    selectedAction = action
    isEnteredFromQuickAction = action == nil ? false : true
}

removeSelectedActionIfNeeded

クイックアクションからのアプリの立ち上げでは無い場合は、選択されたクイックアクションは無い為、selectedActionnilにしています。

func removeSelectedActionIfNeeded() {
    if isEnteredFromQuickAction {
        isEnteredFromQuickAction = false
        return
    }
    selectedAction = nil
}

ContentView

実際に選択されたクイックアクションによって表示を変更するViewを作成します。

import SwiftUI

struct ContentView: View {

    @EnvironmentObject var quickActionState: QuickActionState

    private var backgroundColor: Color {
        switch quickActionState.selectedAction {
        case .flame:
            return .red
        case .water:
            return .blue
        case .thunder:
            return .yellow
        case .none:
            return .white
        }
    }

    var body: some View {
        ZStack {
            Rectangle()
                .fill(backgroundColor)
                .ignoresSafeArea()

            if let action = quickActionState.selectedAction {
                Image(systemName: action.imageName)
                    .resizable()
                    .frame(width: 150,
                           height: 200)
                    .foregroundColor(action == .thunder ? .black : .white)
            }
        }
    }
}

プロパティ

quickActionState

選択されたクイックアクションの状態によってViewを変更する為、EnvironmentObjectを定義します。

@EnvironmentObject var quickActionState: QuickActionState

backgroundColor

選択されているクイックアクションによって動的に変更する背景色を用意しておきます。

private var backgroundColor: Color {
    switch quickActionState.selectedAction {
    case .flame:
        return .red
    case .water:
        return .blue
    case .thunder:
        return .yellow
    case .none:
        return .white
    }
}

body

背景色には上記で用意したbackgroundColorを使用します。表示するImageのシンボルにはQuickAction.imageNameを渡しています。selectedActionnilの場合にはImageは表示されません。

var body: some View {
    ZStack {
        Rectangle()
            .fill(backgroundColor)
            .ignoresSafeArea()

        if let action = quickActionState.selectedAction {
            Image(systemName: action.imageName)
                .resizable()
                .frame(width: 150,
                       height: 200)
                .foregroundColor(action == .thunder ? .black : .white)
        }
    }
}

App

エントリーポイントとなるApp部分の実装です。

import SwiftUI

@main
struct QuickActionsSampleApp: App {

    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    @Environment(\.scenePhase) var scenePhase

    private let quickActionState = QuickActionState.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(quickActionState)
        }
        .onChange(of: scenePhase) { newValue in
            switch newValue {
            case .active:
                quickActionState.removeSelectedActionIfNeeded()
            case .background:
                quickActionState.setActions()
            case .inactive:
                break
            @unknown default:
                fatalError()
            }
        }
    }
}

プロパティ

appDelegate

UIApplicationDelegateAdaptorAppDelegateを呼べるようにしています。

@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

scenePhase

scenePhaseがアクティブなのかバックグラウンドなのかで処理を行いたいのでscenePhase変数を用意します。

@Environment(\.scenePhase) var scenePhase

quickActionState

クイックアクションの状態を見るQuickActionStateを宣言します。

private let quickActionState = QuickActionState.shared

body

ContentViewEnvironmentObjectquickActionStateを渡す必要がある為、渡しています。 また、scenePhaseの状態の変化を見て、activeならremoveSelectedActionIfNeeded()を実行し、backgroundになる際はsetActions()を実行しています。

var body: some Scene {
    WindowGroup {
        ContentView()
            .environmentObject(quickActionState)
    }
    .onChange(of: scenePhase) { newValue in
        switch newValue {
        case .active:
            quickActionState.removeSelectedActionIfNeeded()
        case .background:
            quickActionState.setActions()
        case .inactive:
            break
        @unknown default:
            fatalError()
        }
    }
}

今回は.backgroundになる毎にquickActionをセットする必要はないですが、クイックアクションの文言やアイコン、タイプ等を選択されているページによって変えたい時にはこの.backgroundになる毎にクイックアクションをセットすると良さそうです。

AppDelegate

import UIKit

class AppDelegate: NSObject, UIApplicationDelegate {

    private let quickActionState = QuickActionState.shared

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {

        quickActionState.selectAction(by: options.shortcutItem)

        let configuration = UISceneConfiguration(
            name: connectingSceneSession.configuration.name,
            sessionRole: connectingSceneSession.role
        )
        configuration.delegateClass = SceneDelegate.self
        return configuration
    }
}

configurationForConnecting

この関数は、Sceneが作成される時に、UIKitのための設定を行います。

このメソッドは起動時に一度呼ばれます。

この第三引数optionsは選択されたクイックアクションに紐づくUIApplicationShortcutItemを持っている為、そのshortcutItemselectActionに渡して実行しています。

またSceneDelegateを呼ぶために、configuration.delegateClassに下記で説明するSceneDelegateを渡しています。

SceneDelegate

import UIKit

class SceneDelegate: NSObject, UIWindowSceneDelegate {

    private let quickActionState = QuickActionState.shared

    func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
        quickActionState.selectAction(by: options.shortcutItem)
        completionHandler(true)
    }
}

この関数では、クイックアクションが実行された際に、ユーザーが選んだクイックアクションに紐づくUIApplicationShortcutItemが渡ってきます。そのshortcutItemselectActionに渡して実行しています。

これでクイックアクションによってViewを切り替えれるようになりました。

動的クイックアクションの考慮事項

今回はInfo.plistを使用しない動的クリックアクションを設定する方法を使用しましたが、UIApplicationShortcutItemApp Launch and App Update Considerations for Quick Actionsの項目を見ると、下記の考慮事項が記載ありました。

ユーザーが最初にアプリをインストールした後、最初に起動する前に、ホーム画面のアイコンを押すと、アプリの静的なクイックアクションのみが表示されます。最初の起動後、動的クイックアクション (いずれかを定義しており、リストに対応する余地がある場合) も表示されます。

動的クイックアクションを使用する場合、そのインストール済みのアプリを一度も起動していない時は、クイックアクションが表示されないようです。クイックアクションをセットする処理を書いておけば、一度起動すると設定されクイックアクションが表示されるようです。

おわりに

今回は単純にクイックアクションからViewの見た目を変えるだけでしたが、メモアプリではクイックアクションから直接新規追加のページに飛ばしたり、最後に見たメモを保持しておき、最後に見たメモに飛ばすことも出来そうですね。あまり使用したことはなかったですが、案外便利な機能のような気がしました! 少しずつ使っていきたいと思います。

何かこの機能を使って面白いことが出来ないか考え中です、、。

参考