Aller au contenu
MobileiOS

Implémenter Dynamic Island pour iOS 16.1

Dynamic Island est disponible au travers d’un nouveau framework, ActivityKit. Nouvelle manière de mettre avant son application sans qu'elle ne soit au premier plan.

Dynamic Island

Dynamic Island est disponible au travers d’un nouveau framework, ActivityKit. Il permet non seulement de mettre en place cette nouvelle fonctionnalité mais également de pouvoir ajouter un widget dans l’écran verrouillé du téléphone.
Vous avez envie de rendre votre application encore plus visible pour l'utilisateur et vous souhaitez aller plus loin que l'utilisation de simple Widgets, cet article est fait pour vous !

Si je parle de widget ce n’est pas par hasard, l’intégration UI se fait dans une extension Widget et peut très bien s’ajouter à une implémentation déjà existante. Il n’est cependant pas du tout nécessaire d’être connaisseur du framework WidgetKit pour profiter de la Dynamic Island. La principale différence qui existe avec les widgets existants se retrouve dans la manière de fournir de la donnée.
Il sera cependant nécessaire d'avoir quelques notions sur async/await dès nos premiers pas dans l'implémentation.

Une Live Activity est un évènement que l'on peut démarrer en spécifiant des données initiales. Elle est ensuite potentiellement mise à jour avec de nouvelles données. Enfin elle peut être arrêtée à tout instant.
Si nous prenons un exemple concret, nous pourrions avoir une application de sport qui permet de suivre le score de différents matchs. L'utilisateur pourrait alors demander à suivre plusieurs matchs en parallèle, qui seraient représentés dans l'application comme des Live Activity.

Dans le cadre de cet article, nous allons utiliser un exemple bien plus simple. Notre application propose de lancer un minuteur avec un temps déjà prédéfini. Nous pouvons lancer, mettre en pause et arrêter complètement ce chronomètre. L'exemple nous permet ainsi de voir les différents aspects basiques du sujet de notre article.

LiveActivity

La première chose à faire pour mettre en place une Live Activity est d'ajouter une ligne au fichier Info.plist de votre application. Il faut ainsi mettre la valeur Supports Live Activities (NSSupportsLiveActivities)  à YES pour explicitement dire au système que notre application a l’intention de les utiliser.

La seconde chose à faire est de définir les modèles de données qui seront utilisés pour transmettre des informations de notre application aux widgets de la Dynamic Island ou de l’écran verrouillé.

struct PomodAttributes: ActivityAttributes {
    public typealias PomodStatus = ContentState

    public struct ContentState: Codable, Hashable {
        var isRunning: Bool
        var endTimer: ClosedRange<Date>
    }

    var beganTime: Date
}
Modèle de données implémentant ActivityAttributes

Dans le cas de notre application, nous avons une structure qui contient plusieurs informations :

  • Un typealias pour que le code soit plus facilement lisible à l’utilisation
  • Une structure interne ContentState qui définit les données qui seront mutables dans le temps.
  • Une propriété beganTime qui est une donnée statique qui ne changera pas dans le temps mais qui pourra être utilisée par nos différentes vues.

À partir de ce modèle de données, nous pouvons faire débuter une Live Activity, la mettre à jour et la terminer.

Commencer une Live Activity

func play() {
    let status = PomodAttributes.PomodStatus(isRunning: true, endTimer: Date.now...endDate)
    let attributes = PomodAttributes(beganTime: Date.now)

    do {
        pomodActivity = try Activity.request(attributes: attributes, contentState: status)
    } catch {
        // Handle error
    }
}
Implémentation du démarrage d'une LiveActivity

Le démarrage de la Live Activity correspond, logiquement, au lancement de notre minuteur. On retrouve notre modèle de donnée avec les données mutables dans le temps (status) et les données immutables (attributes). Tous ces éléments sont alors passés à la méthode Activity.request(attributes:,contentState:).

💡 Le résultat de la méthode Activity.request(attributes:,contentState:) est à conserver dans une propriété pour pouvoir par la suite mettre à jour ou terminer la Live Activity.

Mise à jour d’une Live Activity

func pause() async {
    let status = PomodAttributes.PomodStatus(isRunning: false, endTimer: Date.now...endDate)
    await pomodActivity?.update(using: status)
}
Implémentation de la mise à jour d'une LiveActivity

La mise à jour se révèle être très simple. Il suffit de fournir de nouvelles informations au travers du modèle de données mutables à notre Live Activity avec la méthode update(using:).

Mettre fin à une Live Activity

func stop() async {
    let status = PomodAttributes.PomodStatus(isRunning: false, endTimer: Date.now...endDate)
    await pomodActivity?.end(using: status, dismissalPolicy: .default)
}
Implémentation de l'arrêt d'une LiveActivity

Tout comme la mise à jour, la Live Activity est terminée en lui passant des dernières informations qui pourront lui être utile, au travers de la méthode end(using:).

Le paramètre dismissalPolicy est mis à default pour que l’utilisateur ait accès aux dernières informations jusqu’à ce qu’il décide de supprimer la Live Activity. La valeur aurait pu être aussi immediate ou after(Date) qui sont assez explicites dans leur cas.

💡 Le démarrage d’une Live Activity se fait obligatoirement lorsque l’application est en premier plan. Pour la mise à jour et l’arrêt ils peuvent être fait en arrière plan au travers de Background Tasks par exemple.

UI

Intégration dans l’écosystème des widgets

L’intégration se fait de la même manière que pour un widget classique. Si vous en possédez déjà un, n’oubliez pas de passer par un WidgetBundle pour pouvoir déclarer votre nouveau widget en plus de celui que vous avez déjà.
Néanmoins dans tous les cas vous devrez déclarer un widget qui se chargera de définir les vues à la fois pour la Dynamic Island et pour l’écran verrouillé.

@main
struct PomodWidget: Widget {
	let kind: String = "PomodWidget"
	
	var body: some WidgetConfiguration {
	    ActivityConfiguration(for: PomodAttributes.self) { context in
	        lockScreenLiveActivityView(context: context)
	    } dynamicIsland: { context in
	        dynamicIslandActivityView(context: context)
	    }
	}
}

On voit ici que la configuration reprend le modèle de données que l’on a défini plus tôt. On a également, au travers de deux closures, la déclaration des vues qui nous intéressent. On retrouve dans chacune d’elle le context contenant les différentes informations émises depuis l’application.

Avant d’aller plus en détail dans nos vues et pour avoir une meilleure lisibilité, voici le code commun à la Dynamic Island et à l'écran vérouillé qui permet de déclarer une vue Text à partir du contexte et de ses informations.

private func timerText(context: ActivityViewContext<PomodAttributes>) -> some View {
    if context.state.isRunning {
        return Text(timerInterval: context.state.endTimer, countsDown: true, showsHours: false)
    } else {
        let timeInterval = context.state.endTimer.upperBound.timeIntervalSince(context.state.endTimer.lowerBound)
        let formatter = DateComponentsFormatter()
        formatter.unitsStyle = .positional
        formatter.allowedUnits = [.minute, .second]
        formatter.zeroFormattingBehavior = .pad
        return Text(formatter.string(from: timeInterval) ?? "??:??")
    }
}

Ce code permet, dans le cas où le chronomètre tourne, d’afficher un texte avec le temps qui défile grâce à l’utilisation d’un init, très récent (iOS 16.0), de la vue Text.
Dans le cas où le chronomètre est en pause, on affiche alors le temps restant grâce à un DateFormatter classique. Dans les deux cas on récupère les données au travers du context et notamment de sa propriété state qui contient les données mises à jour.

UI pour la Dynamic Island

Prenons un peu de temps ici, sa déclaration n’est pas tout à fait triviale, on ne peut pas tout simplement renvoyer une View. Il faut passer par la structure DynamicIsland et ses différentes contraintes.

La première est le fait qu’elle possède plusieurs états qui auront chacun une vue associée :

  • expanded, apparaît quand l’utilisateur étire la Dynamic Island pour faire apparaître une vue plus grande et donc avec potentiellement plus de détails à afficher
  • compactLeading, présentée quand la Dynamic Island est dans son état classique, est située à gauche de la caméra
  • compactLeading, présentée quand la Dynamic Island est dans son état classique, est situé à droite de la caméra
  • minimal, apparaît quand plusieurs Live Activities sont en cours.
private func dynamicIslandActivityView(context: ActivityViewContext<PomodAttributes>) -> DynamicIsland {
    DynamicIsland {
        DynamicIslandExpandedRegion(.leading) {
            Text(stateText(context:context))
                .foregroundColor(color(context: context))
                .font(.title2)
        }

        DynamicIslandExpandedRegion(.trailing) {
            HStack {
                Spacer()
                timerText(context: context)
                    .foregroundColor(color(context: context))
                    .multilineTextAlignment(.trailing)
                    .monospacedDigit()
                    .font(.title2)
                Image(systemName: "timer")
                    .foregroundColor(color(context: context))
            }
        }

        DynamicIslandExpandedRegion(.bottom) {
            VStack {
                Spacer()
                startedAtText(context: context)
            }
        }
    } compactLeading: {
        Text(stateText(context:context))
            .foregroundColor(color(context: context))
            .font(.caption2)
    } compactTrailing: {
        timerText(context: context)
            .foregroundColor(color(context: context))
            .multilineTextAlignment(.center)
            .frame(width: 40)
            .font(.caption2)
    } minimal: {
        Image(systemName: "timer")
    }
}

La version expanded possède encore d’autres contraintes liées au placement de la Dynamic Island dans l’espace proche de la caméra. Pour simplifier, l'espace est divisé en plusieurs sous-espaces pour lesquels on pourra affecter une vue particulière.
Une image valant mille mots voici le découpage de l’espace.

Découpage de l'espace disponible pour l'UI de la Dynamic Island

Pour chacune des régions on déclarera une vue à l’aide d’une DynamicIslandExpandedRegion à laquelle on passera la région concernée et le contenu que l’on souhaite afficher.
Il n’est pas nécessaire de déclarer une vue pour chaque région. Dans le code d’exemple, aucune vue n’est affectée à la région au centre.

UI pour l’écran verrouillé

Dans le cas de l’écran verrouillé, rien d’aussi complexe à déclarer, une simple HStack suffit. Dans le cadre de notre exemple, nous reprenons les informations principales, le statut et le temps restant.

private func lockScreenLiveActivityView(context: ActivityViewContext<PomodAttributes>) -> some View {
    HStack {
        Text(stateText(context: context))
            .foregroundColor(color(context: context))
            .font(.title2)
        Spacer()
        HStack {
            timerText(context: context)
                .foregroundColor(color(context: context))
                .multilineTextAlignment(.trailing)
                .monospacedDigit()
                .font(.title2)
            Image(systemName: "timer")
                .foregroundColor(color(context: context))
        }
    }.padding(.horizontal, 8)
}
💡 La hauteur maximale autorisée est de 160 points, au-delà, le système tronquera le contenu.

Conclusion

Le sujet est encore très récent et de niche (iPhone 14 Pro et Pro Max). Cependant, les APIs proposées nous permettent déjà de mettre en place des implémentations riches avec une réelle plus value pour l’utilisateur dans son utilisation de nos applications.

Vous avez grâce à l’article toutes les cartes en main pour mettre en place basiquement les nouvelles fonctionnalités du framework ActivityKit.

Documentations

La documentation officielle est très riche sur le sujet et propose des informations plus complètes sur certains des points abordés ou non présents dans cet article. Jetez-y un coup d’oeil en cas de problème !

ActivityKit

WidgetKit

Background Tasks

Dernier