There was announcement at Android Dev Summit 2021 recently about a new Compose based Glance API for the development of Android App Widgets. Given ability since iOS14 to develop iOS App Widgets using SwiftUI I thought it would be interesting to compare approaches used and to do so in a project where both widgets consume the same Kotlin Mutliplatform shared code (in this case in the BikeShare KMM sample).
We release a lot of new things during #AndroidDevSummit!
— Marcel in 🇸🇬 (@marxallski) October 28, 2021
I definitely recommend checking the different talks, but here is a summary of Glance, a new API powered by Compose Runtime to build AppWidget!
A thread 🧵:https://t.co/wjzcAwxP8v
Update Nov 14th 2021: I’ve updated article to reflect recent changes to Glance API.
Compose based Android App Widget
For the Android app widget we’re going to use, as mentioned, the new Compose based Glance APIs. An important thing to note at this point is that Glance is still very much in development (only available right now through snapshot dependencies) and, as such, APIs will almost certainly change.
I’ll cover below the various moving parts/plumbing needed to setup a Glance based widget but interesting to start by looking at the Compose based code for the widget itself. We’ll show details for the iOS widget later and hopefully should be apparent again how similar these approaches and associated code are.
class BikeShareAppWidget(val station: Station? = null) : GlanceAppWidget() {
@Composable
override fun Content() {
Column(
modifier = GlanceModifier
.fillMaxSize().background(Color.White).padding(8.dp),
horizontalAlignment = Alignment.Horizontal.CenterHorizontally,
verticalAlignment = Alignment.Vertical.CenterVertically
) {
station?.let { station ->
Text(text = station.name, style = TextStyle(fontSize = 15.sp, fontWeight = FontWeight.Bold))
Spacer(modifier = GlanceModifier.size(8.dp))
val bikeDrawable = if (station.freeBikes() < 5)
R.drawable.ic_bike_orange
else
R.drawable.ic_bike_green
Image(ImageProvider(bikeDrawable),
modifier = GlanceModifier.size(32.dp),
contentDescription = "${station.freeBikes()}")
Spacer(modifier = GlanceModifier.size(8.dp))
Row {
Text("Free:", modifier = GlanceModifier.width(80.dp))
Text("${station.freeBikes()}")
}
Row {
Text("Total:", modifier = GlanceModifier.width(80.dp))
Text("${station.slots()}")
}
}
}
}
}
To make this widget available we also need to create a GlanceAppWidgetReceiver
.
class BikeShareAppWidgetReceiver : GlanceAppWidgetReceiver(), KoinComponent {
override val glanceAppWidget: GlanceAppWidget = BikeShareAppWidget()
}
And then we can use code elsewhere in project to update widget once new data is available (polling in this case for updated bike share information for particular station)
manager = GlanceAppWidgetManager(context)
...
cityBikesRepository.pollNetworkUpdates("galway").collect {
manager.getGlanceIds(BikeShareAppWidget::class.java).forEach { id ->
// use first station for now
BikeShareAppWidget(it[0]).update(context, id)
}
}
Regarding remaining plumbing required to use this, we needed to add following to AndroidManifest.xml
<receiver android:name="com.surrus.bikeshare.glance.BikeShareAppWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info" />
</receiver>
along with following in res/xml/widget_info.xml
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="120dp"
android:minHeight="120dp"
android:targetCellWidth="2"
android:targetCellHeight="2"
android:minResizeWidth="120dp"
android:minResizeHeight="120dp"
android:maxResizeWidth="240dp"
android:maxResizeHeight="240dp"
android:resizeMode="horizontal|vertical" />
SwiftUI based iOS App Widget
For the SwiftUI based iOS app widget I took inspiration from this post by Majid Jabrayilov.
As with the Android app widget we’ll start by showing the code for the widget itself.
struct BikeShareStationEntry: TimelineEntry {
let date: Date
let station: Station?
}
struct BikeShareWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
if let station = entry.station {
VStack() {
Text(station.name).font(.headline)
Image("ic_bike").resizable()
.renderingMode(.template)
.foregroundColor(station.freeBikes() < 5 ? .orange : .green)
.frame(width: 32.0, height: 32.0)
HStack() {
Text("Free:").font(.subheadline).frame(width: 80, alignment: .leading)
Text("\(station.freeBikes())").font(.subheadline)
}
HStack {
Text("Total:").font(.subheadline).frame(width: 80, alignment: .leading)
Text("\(station.slots())").font(.subheadline)
}
}
}
}
}
@main
struct BikeShareWidget: Widget {
let kind: String = "BikeShareWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
BikeShareWidgetEntryView(entry: entry)
}
.configurationDisplayName("BikeShare")
.description("Bike Share Widget")
}
}
We also need to provide a TimelineProvider
to provide the data needed for the widget. Note that we’re using same fetchBikeShareInfo
function from shared Kotlin Multiplatform code that our Android widget is using.
final class Provider: TimelineProvider {
var timelineCancellable: AnyCancellable?
private var entryPublisher: AnyPublisher<BikeShareStationEntry, Never> {
let future = Future<BikeShareStationEntry, Never> { promise in
let repository = CityBikesRepository()
repository.fetchBikeShareInfo(network: "galway") { data, error in
if let stationList = data {
promise(.success(BikeShareStationEntry(date: Date(), station: stationList[0])))
}
if let errorReal = error {
print(errorReal)
}
}
}
return AnyPublisher(future)
}
init() {
KoinKt.doInitKoin()
}
func placeholder(in context: Context) -> BikeShareStationEntry {
BikeShareStationEntry(date: Date(), station: nil)
}
func getSnapshot(in context: Context, completion: @escaping (BikeShareStationEntry) -> ()) {
let entry = BikeShareStationEntry(date: Date(), station: nil)
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
timelineCancellable = entryPublisher
.map { Timeline(entries: [$0], policy: .atEnd) }
.receive(on: DispatchQueue.main)
.sink(receiveValue: completion)
}
}
As we’re consuming shared Kotlin Multiplatform code in this project using CocoaPods
we also needed to add following to Podfile
for the iOS project and run pod install
.
target 'BikeShareWidgetExtension' do
pod 'common', :path => '../../common'
end
And finally this is what those widgets looks like right now on iOS and Android
Featured in Android Weekly Issue #491 and Kotlin Weekly Issue #276
Related tweet
Widgets, widgets everywhere! BikeShare #KMM sample (https://t.co/9ghVs8MAOj) now includes
— John O'Reilly (@joreilly) November 4, 2021
- iOS app widget using SwiftUI
- Android app widget using new Compose based Glance API (using snapshot version right now)
Both of these are consuming same shared Kotlin Multiplatform code. pic.twitter.com/s6P7yxyNjw