iOS版マネーフォワード ME クイックアクションウィジェットの開発

こんにちは。
マネーフォワード MEのiOSエンジニア、ひらた( @cafedeichi )です。

iOS版マネーフォワード MEは、Ver. 13.8でダークモードに対応しました。Twitter等のSNSでかなり話題になっていたようですが、その影でひっそりと新機能である「クイックアクションウィジェット」もリリースしました。

今回は可愛い我が子をもっと見てもらおうということで、そのデザインと開発についてまとめてみました。Intentsを使った実装や、XcodeGenによるExtensionのTarget設定など、参考にできる文献が少なく一次情報を頼りに手探りで行ったところもありますが、これからConfigurableなウィジェットを開発しようと思っている方の参考になれば幸いです。

クイックアクションウィジェットとは

まずご存知ない方向けに。
クイックアクションウィジェットとは、アプリの中で普段よく使う画面へのショートカットを実現するウィジェットです。

編集画面から遷移先の入れ替えと並べ替えが可能です。

デザイン

ウィジェットのデザインはWidget – iOS Human Interface Guidelinesを参考に行いました。また、ウィジェットはiOSのホームスクリーンで表示されるので、iOSのUI/UXとシームレスな関係となることを意識して、システム標準のものを可能な限り利用しました。

フォント

システムフォントを使い、サイズは指定せず、SwiftUIのFontプロパティを活用しました。これにより端末の環境設定(アクセシビリティの設定による変更など)に応じて最適なサイズで画面に表示できます。

Consider using SF Pro. Using the system font helps your widget look at home on any platform, while making it easier for you to display great-looking text in a variety of weights, styles, and sizes.
(Designing a Beautiful Widget – Widgets – iOS Human Interface Guidelines)

色はColor – iOS Human Interface Guidelinesを参考にUI Element Colorsを使用しました。自動的かつ容易に(アプリ本体と同じく)ダークモードに対応するのが主な目的です。

Support Dark Mode. A widget should look great in both the light and dark appearances. In general, avoid displaying dark text on a light background for the dark appearance, or light text on a dark background for the light appearance.
(Designing a Beautiful Widget – Widgets – iOS Human Interface Guidelines)

背景色

背景色もダークモードに容易に対応するために、UI Element Colorsを使用しました。

アイコン

アイコンに限ってはシステム標準のSF Symbolsは使わず、アプリ本体の各画面への導線を表す既存のアイコンと同じものにして、遷移先を容易に想起できるようにしました。

ウィジェットのサイズ

ウィジェットのサイズはMediumの1種類としました。他のサイズについては、Smallは一つのショートカットしか提供できないこと、Largeはインタラクションが多すぎること、またショートカットだけの提供はiOS Human Interface Guidelinesの観点からもふさわしくないと判断し、対応を見送りました。

Avoid defining too many interaction targets. A small widget supports a single target, but larger widgets can offer multiple targets. For example, the medium Notes widget can display several notes. When people tap one of them, the app opens to display that note. Although multiple interaction targets might make sense for your content, avoid offering so many that people have to spend time choosing the one they want.
(Supporting Editing and Interactivity – Widgets – iOS Human Interface Guidelines)

開発

構成要素と実装

ウィジェットを構成する各要素を図にまとめると以下になります。実装はこのブログ用に編集しているため、実際のものとは一部異なる箇所があります。

Widget

struct QuickActionWidget: Widget {
    let kind: String = "QuickActionWidget"

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind,
                            intent: QuickActionConfigurationIntent.self,
                            provider: QuickActionProvider()) { entry in
            QuickActionWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("クイックアクション")
        .description("よく使う画面へのショートカットをご利用になれます。")
        .supportedFamilies([.systemMedium])
    }
}

TimelineProviderの管理、TimelineEntryのViewへの明け渡しなど、コンテンツの表示処理全般を担います。Configurableなウィジェットにするために、StaticConfigrationの代わりにIntentConfigurationを使用しています。ウィジェットギャラリーに表示されるウィジェットの説明文言や、サポートするウィジェットのサイズもここで決定します。

TimelineProvider(必須プロトコル)

struct QuickActionProvider: IntentTimelineProvider {
    func placeholder(in context: Context) -> QuickActionEntry {
        QuickActionEntry(date: Date(), configuration: QuickActionConfigurationIntent())
    }

    func getSnapshot(for configuration: QuickActionConfigurationIntent, in context: Context, completion: @escaping (QuickActionEntry) -> Void) {
        let entry = QuickActionEntry(date: Date(), configuration: configuration)
        completion(entry)
    }

    func getTimeline(for configuration: QuickActionConfigurationIntent, in context: Context, completion: @escaping (Timeline<QuickActionEntry>) -> Void) {
        let entry = QuickActionEntry(date: Date(), configuration: configuration)
        let timeline = Timeline(entries: [entry], policy: .never)
        completion(timeline)
    }
}
  • Placeholder
    • ロード中のプレイスホルダーの表示を行います。このウィジェットでは通信を伴わないので実質的な役割はありません。
  • Snapshot
    • ウィジェットギャラリー(iOSのウィジェット追加・編集画面)でのデータ表示を行います。IntentHandlerdefaultTransitionDestinationsメソッドで生成したINObjectが表示されます。
  • Timeline
    • 実際にウィジェットの使用中に表示されるデータとなります。初回はIntentHandlerdefaultTransitionDestinationsメソッドの返り値であるINObjectが表示されます。ショートカットの入れ替え、並べ替えを行った際は、IntentHandlerprovideTransitionDestinationsOptionsCollectionメソッドの返り値であるINObjectが表示されます。なお、このウィジェットは定期的な更新を必要としないので、TimelineReloadPolicyneverとなっています。

TimelineEntry(必須プロトコル)

struct QuickActionEntry: TimelineEntry {
    let date: Date
    let configuration: QuickActionConfigurationIntent
}

dateプロパティとCustom Intentプロパティで構成します。dateにはウィジェットの更新日時が入ります(TimelineProviderで決定されます)。Custom IntentはIntent DefinitionファイルからXcodeが自動生成され、その内容はIntentHandlerで更新されます。

View

struct QuickActionWidgetEntryView: View {
    var entry: QuickActionProvider.Entry
    let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 2)

    var body: some View {
        let items = entry.configuration.transitionDestinations ?? []
        LazyVGrid(columns: columns, spacing: 8) {
            ForEach(items, id: \.self) { item in
                Link(destination: item.urlScheme!, label: {
                    RoundedRectangle(cornerRadius: 8)
                        .foregroundColor(Color(UIColor.secondarySystemBackground))
                        .frame(height: 58)
                        .overlay(
                            HStack(spacing: 0) {
                                Image(item.imageName ?? "")
                                    .renderingMode(.template)
                                    .resizable()
                                    .aspectRatio(contentMode: .fit)
                                    .foregroundColor(Color(UIColor.systemOrange))
                                    .frame(width: 32)
                                    .padding(8)
                                Text(item.displayString)
                                    .font(.footnote)
                                    .foregroundColor(Color(UIColor.label))
                                Spacer()
                            }
                        )
                })
            }
        }
        .padding(.all)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        .background(Color(UIColor.systemBackground))
    }
}

TimelineProviderから渡されるTimelineEntryを元に、コンテンツの表示を行います。
ViewはSwiftUIでのみ実装が可能となっています。

Intent

IntentHandlerにより変更が可能なプロパティをウィジェットに持たせます。Widget ExtensionのTargetにIntent Definitionファイルを追加し、Custom IntentsTypesを設定します。

  • Custom Intents
    • ウィジェットの編集画面でユーザーに選択させる項目を定義します。Xcodeはこのファイルを元にINIntentを継承したクラス(QuickActionConfigurationIntent)を自動生成します。
  • Parameters
    • ウィジェットの編集画面でユーザーに選択させる項目の詳細を定義します。Xcodeはこのファイルを元にINObjectを継承したクラス(TransitionDestination)を自動生成します。

IntentHandler

ウィジェットの編集画面に動的なパラメータを持たせます。Intents ExtensionのTargetを追加する必要があります。

  • Supported Intents
    • Intent Definitionファイルで定義したCustom Intentを指定することで、IntentHandlerCustom Intentが使用できるようになります。

final class QuickActionIntentHandler: INExtension {
    override func handler(for intent: INIntent) -> Any {
        return self
    }

    private let allActions: [(identifier: String, screenName: String, imageName: String, urlScheme: String)] = [
        (identifier: "1",
         screenName: "入力",
         imageName: "input",
         urlScheme: "hoge://hoge/input"),
        (identifier: "2",
         screenName: "レシート撮影",
         imageName: "camera",
         urlScheme: "hoge://hoge/input"),
        (identifier: "3",
        screenName: "入出金",
        imageName: "transaction",
        urlScheme: "hoge://hoge/transaction"),
        (identifier: "4",
        screenName: "家計簿",
        imageName: "cashflow",
        urlScheme: "hoge://hoge/cashflow"),
        ...
    ]
}

extension QuickActionIntentHandler: QuickActionConfigurationIntentHandling {
    /// 初期設定(ギャラリーでの表示時、または端末のホーム画面追加時の状態)
    func defaultTransitionDestinations(for intent: QuickActionConfigurationIntent) -> [TransitionDestination]? {
        // 先頭4件を取得
        return allActions.prefix(4).map {
            let transitionDestination = TransitionDestination(identifier: $0.identifier, display: $0.screenName)
            transitionDestination.imageName = $0.imageName
            transitionDestination.urlScheme = URL(string: $0.urlScheme)
            return transitionDestination
        }
    }

    /// 遷移先をタップした時に選択可能なその他の遷移先一覧を表示する。
    func provideTransitionDestinationsOptionsCollection(for intent: QuickActionConfigurationIntent, with completion: @escaping (INObjectCollection<TransitionDestination>?, Error?) -> Void) {
        let items: [TransitionDestination] = allActions.map {
            let transitionDestination = TransitionDestination(identifier: $0.identifier, display: $0.screenName)
            transitionDestination.imageName = $0.imageName
            transitionDestination.urlScheme = URL(string: $0.urlScheme)
            return transitionDestination
        }.filter {
            guard let destinations = intent.transitionDestinations else {
                assert(false)
                return false
            }
            // 選択済みの遷移先は選択不可とする。
            return !destinations.contains($0)
        }

        let collection = INObjectCollection(items: items)

        completion(collection, nil)
    }
}

ウィジェットに表示するコンテンツ一式を生成します。

  • defaultTransitionDestinations
    • ギャラリーや端末のホームスクリーンに追加した時の初期表示状態を構成します。
  • provideTransitionDestinationsOptionsCollection
    • ウィジェットの編集画面で選択可能な、その他のショートカットの一覧を構成します。

XcodeGenの設定

iOS版Money Forward MEでは、XcodeGenを既に導入しています。 そのためWidget ExtensionとIntents ExtensionのTargetもXcodeGenを通して生成されるようにしました。

xcconfigの作成

xcconfig-extractorを使用しました。
通常の手順でXcodeプロジェクトにTargetを追加した後に、xcconfig-extractorを実行してxcconfigファイルを作成し、細かい箇所は手動で編集しました。

project.ymlの更新

typeapp-extensionを指定することで、ExtensionのTargetを生成できます。

Widget Extension

QuickActionWidget:
type: app-extension
platform: iOS
sources:
  - path: MoneyForwardWidgets/QuickActionWidget
    createIntermediateGroups: true
    # ...
  - path: MoneyForward/Resources/images.xcassets
configFiles:
  # ...
dependencies:
  - sdk: SwiftUI.framework
  - sdk: WidgetKit.framework
scheme:
  # ...
  environmentVariables:
    - variable: "_XCWidgetKind"
      value: ""
      isEnabled: false
    - variable: "_XCWidgetDefaultView"
      value: "timeline"
      isEnabled: false
    - variable: "_XCWidgetFamily"
      value: "medium"
      isEnabled: false

アイコン等はアプリ本体のリソースを共通で使うので、Assetのパスを通しています。schemeenvironmentVariablesは、Widget ExtensionのTargetを通常の手順で作成したときの状態を参考に設定しました。

Intents Extension

QuickActionIntent:
type: app-extension
platform: iOS
sources:
  - path: MoneyForwardWidgets/QuickActionIntent
    createIntermediateGroups: true
  # ja.lproj配下のIntent Definitionファイルを単体参照するためにディレクトリを作っている
  - path: MoneyForwardWidgets/QuickActionWidget/Resources/Intents
    createIntermediateGroups: true
configFiles:
    # ...
dependencies:
  - sdk: Intents.framework
scheme:
    # ...

Intent Definitionファイルは、ローカライズをしていないとApp Store Connectへのアップロード時に”ITMS-90626: Invalid Siri Support”の警告を受けるので、日本語にローカライズしています。ローカライズされたファイルのパス指定はXcodeGenではできなかったので(Ver. 2.18.0時点)、Intent Definitionファイルだけがあるパスを指定して問題を回避しました。
ちなみに以下の設定を試してみましたが有効ではありませんでした。

# Xcode上で見られるパスでは、XcodeGenはファイルを見つけられない。
path: MoneyForwardWidgets/QuickActionWidget/Resources/Intents/QuickActionWidget.intentdefinition
# 実際のパスを指定するとXcodeプロジェクトには組み込まれるが、ファイルは正しく表示されない。
path: MoneyForwardWidgets/QuickActionWidget/Resources/Intents/ja.lproj/QuickActionWidget.intentdefinition

アプリ本体

最後にWidget Extension、Intents ExtensionのTargetをアプリ本体のdependenciesに記述します。
このウィジェットでは必要ないのですが、もしアプリ側でウィジェットを更新する(reloadAllTimelines()等を使用する)場合は、WidgetKit.frameworkも追記する必要があります。
WidgetKit.frameworkはiOS 14以上のみ利用可能なので、iOS 13以下をサポートするアプリの場合はweak: trueとすることでOptionalにして、iOS 13以下では参照されないようにします。

dependencies:
  - target: QuickActionWidget
  - target: QuickActionIntent
  - sdk: WidgetKit.framework
    weak: true  

完成


以上、クイックアクションウィジェットのデザインと開発について説明しました。詳しい使い方はFAQをご覧ください。また、ここではとりあげませんでしたが「入出金」のウィジェットもあります。ぜひそちらもご利用ください。

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


マネーフォワードでは、エンジニアを募集しています。
ご応募お待ちしています。

【サイトのご案内】
マネーフォワード採用サイト
Wantedly
京都開発拠点

【プロダクトのご紹介】
お金の見える化サービス 『マネーフォワード ME』 iPhone,iPad Android

ビジネス向けバックオフィス向け業務効率化ソリューション 『マネーフォワード クラウド』

おつり貯金アプリ 『しらたま』

お金の悩みを無料で相談 『マネーフォワード お金の相談』

だれでも貯まって増える お金の体質改善サービス 『マネーフォワード おかねせんせい』

金融商品の比較・申し込みサイト 『Money Forward Mall』

くらしの経済メディア 『MONEY PLUS』

Pocket