Using SwiftUI and Compose to develop App Widgets on iOS and Android

Share on:

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).

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

widgets screenshot


Featured in Android Weekly Issue #491 and Kotlin Weekly Issue #276