Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Studyplus iOSアプリでWidgetに対応しました

初めまして、モバイルクライアントグループの上原です。昨年11月からiOSアプリ開発を担当しています。 最近は、Apex Legendsで目標だったランクのダイヤ4に到達し、ランクのモチベーションが下がりカジュアルをずっと回す日常になりました。

さて、本題に入ります。Studyplusでは、iOS 14から実装されたWidgetに対応し、昨年11月30日にカウントダウンWidgetをリリースしました。
今回はどのようなWidgetを作成したのか、導入経緯やTipsなどを紹介していこうと思います。

カウントダウンWidget

Studyplusでは、アプリ内でユーザが設定したイベント(模試や期末テストなど)までの日数を表示するイベントカウントダウン機能を提供しています。
上記の機能を、ホーム画面でも確認できるようにしたのがカウントダウンWidgetです。

f:id:nappannda:20210120155037p:plain:w200
カウントダウンWidget画像

Widgetの設定画面からアプリ内で設定しているカウントダウンを選択したりシンプルモードといった形で端末の外観モードに合わせた表示ができるようになっています。

f:id:nappannda:20210118072955p:plain:w200
Widget設定画面画像

f:id:nappannda:20210118073957p:plain:w200
カウントダウンWidget ライトモード
f:id:nappannda:20210118073912p:plain:w200
カウントダウンWidget ダークモード

Widget実装経緯

iOS 14から実装されたWidgetですが、Studyplusで実装に至った経緯は下記になります。

エンジニアからiOS 14の新機能のどれかを作りたい意見が出る
WidgetがSwiftUIのみで書くものだったので今後のSwiftUI環境に向けての勉強になりそう、Widgetを作りたい旨を伝える
ディレクターやデザイナーとユーザへの新しいアプローチでユーザ価値が出せるものがないかを検討
ユーザの学習に対して緊張感・危機感を高めるものとしてカウントダウンWidgetを実装

アプリを触っていただいた方には伝わるかなと思うのですが、イベントまでの日数がカウントダウンWidgetに表示されていると、スマホを開くたびに緊張感が高まり学習の習慣化を促すことができ新しい価値を提供することができたと思います。 しかし、SwiftUIの学習にWidgetが利用できたかというとViewの少しの実装に関しては利用できましたが、やはり@Stateや@ObservedObjectなどで値が更新されたらViewを更新するなどSwiftUIの肝となる部分などはWidgetではサポートされておらずSwiftUIの学習面では少し微妙だなと感じました。

実装Tips

Widgetで実装した機能のなかでどうやって実装したかどうかなどを紹介していきます。

シンプルモード ON/OFF時のviewへのShadowオンオフ

カウントダウンWidgetでは、シンプルモードではない時に特定のViewにShadowを付け、シンプルモードではShadowを付けないといった仕様がありました。 何も考えずにSwiftUIで愚直にやろうとすると下記のようなコードになります。 条件によってほぼ同じViewが存在してしまったり、見た目をカスタマイズしようとすると分岐が複雑化してしまったりと、見にくいコードになってしまいます。

let isSimpleMode: Bool
var body: some View {
    if !simpleMode {
        Text("タイトル").shadow(color: .init(red: 0, green: 0, blue: 0, opacity: 0.75), radius: 0.5, x: 0.5, y: 0.5)
    } else {
        Text("タイトル")
    }
}

上記をもっとスマートにある特定の条件式の場合であればShadowを付けたいですよね?
下記のブログで紹介されているViewにExtensionで条件に適していればクロージャーを実行、適していなければそのままViewを返すコードを実装すればこのコードがすっきりします。

extension View {
    @ViewBuilder
    func `if`<Content: View>(_ condition: Bool, content: (Self) -> Content) -> some View {
        if condition {
            content(self)
        }
        else {
            self
        }
    }
}

blog.kaltoun.cz

上記を適用したコードが下記になります。同じようなViewが複数定義されることなく条件によって何が適用されるかが分かりやすくなりました。

let isSimpleMode: Bool
var body: some View {
    Text("タイトル")
        .if(!isSimpleMode) {
            $0.shadow(color: .init(red: 0, green: 0, blue: 0, opacity: 0.75), radius: 0.5, x: 0.5, y: 0.5)
        }
}

Widgetを押した際にアプリの特定画面に飛ばしたり、Widgetからの起動を計測する

Widgetは要素を押した際にURLを渡すことができます。 この機能を利用するとURLを解析しアプリの特定画面を開いたり、Widgetからの起動を計測したりすることができます。
具体的には、widgetURLにURLを渡すことで要素を押した時にそのURLが開くことになります。
注意事項としてWidgetのサイズがSmallでは、一つしか遷移に利用できません。Medium以上だと複数のwidgetURLを定義して利用することができます。

var body: some View {
    VStack {
        Text("タイトル")
        Text("サブタイトル")
    }.widgetURL(URL("app://countdown"))
}

実装ではまった&困惑したところ

TextのfontSizeを48以上に指定するとSimulator上で一瞬表示された後、消える

Xcode 12.1 ~ 12.3時点でビルドしたSimulatorで発生することを確認した挙動です。 Simulatorのみで起きており実機では再現しないのでfontSize 48以上の指定で実装した場合は、実機で確認する必要があります。

Widgetの処理がブレークポイントで止まらない

ビルド後に上部メニューからDebug->Attach to Processを選択しその中からWidgetを選択すると止まるようになります。
時々止まらないこともあるので、その時は端末からWidgetを削除したりXcode再起動を試すと上手くいくと思います。

最後に

Widgetはいろいろ制約がありますが、その制約が強いことでユーザにシンプルな情報を提供できるように感じました。 そして、制約の強さがWidgetの実装が複雑化しないようになっているのかなと実装していて感じました。
また、新しい機能ということもあり実装情報が少なかったり、予期せぬ動作が起きたり実装していくなかで様々なことがありましたが新しいものに触るのは大変面白くいい経験でした。