Why Shopify Revisited Its Android Widgets
When iOS 14 debuted its widget framework, our iOS team immediately began designing an experience around the new platform. But widgets have been part of Android for over a decade, and Shopify's mobile apps have always shipped features one-to-one across both platforms. The iOS spotlight gave us a reason to re-examine what we were offering Android merchants.
Our widgets focus on analytics that give merchants a quick read on their business performance. Monitoring those metrics is a daily habit for many merchants, and widgets deliver that data faster than opening an app or checking the web. After several iterations and merchant feedback, we've landed on our third version of the Android implementation, and we want to share the architectural decisions and obstacles that shaped it.
Why Not React Native?
Shopify has committed to React Native for new development and is migrating existing apps, including the flagship admin app that pairs with these widgets. So the natural question is: why build the widgets natively?
The answer came quickly during initial investigation. Android widgets must be built with RemoteViews, and there's no official React Native community support for that API. Pushing React Native into that constraint felt like forcing a square peg into a round hole. Our iOS team had hit similar walls with React Native, and we took the same lesson here: use the right tool for the job. Native development was the right call for widgets.
Data Freshness and Fetching Strategy
Some widgets — like reminders, calendars, or weather — can work with data that updates rarely or is predictable for the day. Our merchants need up-to-date business metrics, so stale data isn't just an annoyance; it can delay a decision or hide a problem. At the same time, we have to respect the system's battery optimization and the merchant's data plan.
Android gives us more flexibility than iOS here. The widget configuration screen runs in the same context as any other app screen, with full access to app libraries and resources. We don't need to duplicate code between the app and widget layers. When a merchant saves a configuration, we persist it in shared preferences, and that data is read by the widget update flow to build the appropriate request. The same data is available elsewhere in the app if we ever want to reuse it.
A Short-Lived Cache to Stop Redundant Calls
Android widgets call onUpdate() more than once per update cycle — we discovered that empirically in our second iteration. For a calendar widget reading local data, those extra calls are cheap. For us, they triggered two to five extra network requests per widget, all fetching identical data within seconds.
We solved it with a simple short-lived cache:
- Our widget receives an update request from the system.
- We check a local cache keyed by the widget ID the system assigns.
- If the data was stored less than one minute ago, we return it and skip the network request. We also factor in configuration details like locale, so a language change still forces a fresh fetch.
- Otherwise, we fetch from the backend, store the result with a timestamp, and display it.
This cut unused network calls, reduced system load, and prevented incorrect analytics from spurious requests.
Dynamic Queries With a Decoder Strategy
Like our iOS app, the Android widget builds a query set based on what the merchant configured. Each supported metric is backed by a definition implementation, which gives that metric full control over the data it requests and how it parses the response. When the system triggers an update, we pull the merchant's choices from the configuration object, map the chosen metric IDs to their definitions, and generate query parameters.
Our service returns an array of responses corresponding to the queries we made. We bind the original definitions to a response extension and walk through the data, using each definition's decoder to map its chunk to the expected return type. That keeps each metric independent while letting the widget compose them into a single update request.
Designing the Layout System
Android widgets follow similar size conventions to iOS for versions prior to Android 12, with one key difference: the small widget. On Android, the smallest size is a 2x1 grid and supports only a single metric, whereas iOS allows two. Our developers determined that squeezing more than one metric into this space wasn't feasible, so the two platforms diverge slightly at this size.
Previewing widgets was also a different challenge than with Swift previews on iOS. XML previews are limited, and we build widgets dynamically, so we couldn't rely on static previews. We ran layouts on emulators or devices for verification. The 2022 Jetpack Compose roadmap includes widget support, so composable previews should improve this workflow in the near future.
Android 12 introduced dynamic layouts, requiring us to create five additional sizes to bridge the gaps between the original three. These intermediate sizes are exclusive to Android, and we adopted grid dimensions in our naming scheme, as reflected in our WidgetLayout enum.
We use an enum-driven blueprint to map each widget area to its layout file. Adding a new widget is simplified to adding a new enum configuration. The widget itself is built dynamically by reading the configuration from shared preferences and feeding it into the RemoteViews API.
Rebuilding RemoteViews Without Duplication
The default updateView() method doesn't exist in the RemoteViews API; it's an extension we added to solve a specific bug. Widget updates append new remote views to the existing stack rather than replacing them, causing layout corruption that worsens with each refresh. We fixed this by chaining removeAllViews() and addView() on the parent view. We also tested partiallyUpdateAppWidget() but encountered the same duplication, so this combination remains necessary.
After constructing the final remote view, we pass it to the updateAppWidget() method of AppWidgetProvider to render the intended layout.
Displaying a Last-Updated Timestamp
Showing when data was last refreshed is important. A merchant who glances at stale data might assume it's current, leading to confusion. Our older small widgets displayed a long text timestamp that sometimes wrapped across two lines on compact devices. With our newer, denser layouts, that approach no longer fits comfortably.
We considered adopting relative time, showing "1 min, 3 sec ago" instead of an absolute "as of 3:30pm". However, dynamic dates require frequent updates, which Android restricts to preserve battery and resources. TextClock is a built-in alternative, but it doesn't support relative formatting. Scheduling frequent Alarms would consume too much power.
Testing widgets under constrained conditions — low battery or poor network — proved instructive. These scenarios are far more restrictive for background updates than a general app, and the OS frequently skips them. We ultimately stuck with a static timestamp format, retaining full control over formatting and style without the headache of repeated refreshes.
User Configuration Without RemoteViews
Widget configuration gives merchants control over store selection, metric count, and date range for each size. Unlike the widget itself, configuration screens aren't bound to the RemoteViews API, so any standard view type is fair game. We persist configuration choices to shared preferences, which the widget reads on each update.
Supporting charts required creativity since RemoteViews only accommodates basic text and images. We already used the MPAndroidCharts library for sparklines and spark bar charts in the main app. Since it renders charts through canvas drawing, we export the chart as a bitmap and display it in a standard ImageView. For transparent chart backgrounds, the bitmap must use the ARGB_8888 config. This bitmap-to-image approach handles any custom drawing we need.
Reducing Widget Flicker
During data refreshes, Android shows the initialLayout placeholder, causing a visible flicker while new remote views are constructed. We can't prevent this behavior, so we implemented two strategies to mask its impact.
The first strategy reduces how often flicker occurs. When a merchant first places a widget, the OS fires multiple triggers. Our short-lived cache (previously mentioned) kicks in: after the first successful data load, we suppress further updates for 60 seconds to avoid a sequence of rapid flickers.
The second strategy shortens the flicker duration. We store the widget's state in shared preferences every time it updates, keeping a snapshot of what's displayed. When onUpdate() fires, we call renderEarlyFromCache() immediately, using the snapshot as an interim layout until fresh data arrives.
Tracking Usage With Analytics
Analytics informed improvements to our first widgets. Dashboards from the data team tracked install counts, popular metrics, and size preferences, all sourced from events sent from widgets and the Shopify app.
For the updated widgets, we wanted insight into adoption and retention over time, so we capture configuration events and widget lifecycle changes. Android makes this easier than iOS. Sending an event when a merchant saves configuration covers widget additions. Removals hook into the built-in onDeleted callback, which provides the widget ID of the removed widget, enabling us to look up and log its details before deleting its configuration from the device.
Upgrading to Android 12 Widgets
Shopify’s Android app was still targeting Android 11 when work on the new widgets started. The team chose to defer Android 12-specific behavior until after the target SDK bump, a decision that later caused a visible problem for existing widget users.
Previously, the widget picker offered separate small, medium, and large widgets. Android 12’s responsive layouts change that model: one widget can be resized to any of those sizes and everything in between. The team disabled the small and medium entries for Android 12 by adding a flag in the AndroidManifest and setting it per version in attrs.xml. That kept the new widgets out of the picker as intended. But merchants who had already placed a medium or small widget on Android 12 before the update found those widgets disappearing from their home screens entirely — an outcome easily mistaken for a bug.
The decision to keep only the large widget was data-driven. Launch tracking showed it was the most-used size, so removing it would have had the greatest impact. In hindsight, prototyping the upgrade path for an existing Android 12 user would have surfaced this issue earlier.
Layout Size Limits
While building the new responsive layouts, the team ran into an error related to the Binder transaction buffer. The documented limit is 1MB, yet the error appeared at 0.66MB — a discrepancy that has apparently confused many developers. Experimenting with ways to shrink the payload, the team had two options: drop whole layouts or reduce the number of data rows in the small metric. They chose the latter, which is why the 2×3 widget shows three rows of data even though the layout has room for five.
Configuration Screen Rework
With only one widget size to present, the configuration screen could no longer show a fixed set of metrics. The team went with displaying the maximum number available across all sizes — seven at the time — which is also a requirement of how responsive layouts work. Android must know every possible layout in advance. Even if the widget is shrunk to show a single metric, Android needs to know what the other six are so resizing to the largest layout proceeds without issue. The screen’s description was updated to explain this behavior.
Analytics Gaps on Android
On iOS, the team captures analytics when a merchant reconfigures a widget. Android 12 made reconfiguration possible, but the AppWidgetProvider’s onAppWidgetOptionsChanged() method blocked the same data collection. The method does supply width and height in dp, but the team found those measurements couldn’t be reliably mapped back to their breakpoint-based layout definitions. Testing on multiple devices produced inconsistent results that would have generated poor analytics. The event was omitted from Android entirely, though the team hopes to see a fix in a future Android release.
Shipping and Migration
Because Shopify already had two widgets in production, the new widgets replaced an existing implementation — yet Android documentation only covers enhancing a widget, not replacing it. The existing widgets had fixed, square dimensions; the new ones expand to fill available space. There was no way to guarantee the new widget would fit into the space the old one occupied, ruling out a one-to-one transformation approach.
The compromise was to deprecate the old widgets at the same time the new one shipped. The old widgets’ UI was updated with a deprecation message, since widgets cannot be added programmatically and tapping one can’t take the merchant to the picker. The team also updated help center documentation, linked the old widgets to that documentation, and left the deprecation message in place for a long period before removal. It wasn’t an ideal transition, but it avoided removing every widget from affected merchants’ screens overnight.
Future Plans
As usage data comes in for the new generation of widgets, Shopify will continue tuning the experience across platforms. The groundwork opens the door for other teams to build additional widgets. With WatchOS about to get a WidgetKit refresh, the team is also looking at bringing watch support to Android merchants via WearOS.



