In a world of information overload, home screen widgets offer a powerful way to provide users with glanceable, relevant data from your app without requiring them to open it. For health and fitness applications, this is a game-changer. Imagine your users seeing their daily step count or sleep score every time they unlock their phone – a constant, subtle source of motivation.
This tutorial will guide you through the process of building custom home screen widgets for both iOS and Android within a React Native application. We will create a "Health Stats" widget that displays a user's daily steps and sleep score. While React Native doesn't have a built-in "widget" component, we can leverage native modules to create these powerful extensions.
Prerequisites:
- Basic understanding of React Native.
- Node.js and npm/yarn installed.
- A React Native development environment set up for both iOS and Android.
- For iOS development: a Mac with Xcode installed.
- For Android development: Android Studio installed.
- An active Apple Developer account is required for App Group capabilities on iOS.
Why this matters to developers:
Widgets can significantly increase user engagement and app retention. By the end of this tutorial, you'll have the skills to build custom widgets for your React Native apps, providing a more integrated and valuable user experience.
Understanding the Problem
The primary challenge in creating widgets with React Native is that widgets are fundamentally native UI components. They run in a separate process from your main application and cannot directly render JavaScript components. Therefore, we need to build the widget's UI using the platform's native tools (SwiftUI for iOS and XML layouts for Android) and establish a reliable method for sharing data from our React Native app to these native widgets.
Our approach will be to:
- Create the widget UI natively for each platform.
- Establish a shared data layer that both the React Native app and the native widget can access.
- Write data from our React Native app to this shared storage.
- Read and display the data in our native widgets.
Prerequisites
Before we start, make sure you have a React Native project set up. If not, you can create one using the following command:
npx react-native init HealthStatsWidgetApp
cd HealthStatsWidgetApp
We will also need a library to facilitate data sharing between the React Native app and the native widgets. For this, we'll use react-native-shared-group-preferences for iOS and a custom native module for Android.
Step 1: Building the iOS Widget
What we're doing
For iOS, we'll create a "Widget Extension" in Xcode and use SwiftUI to design our widget's UI. We'll then set up an "App Group" to enable data sharing between our main app and the widget extension.
Implementation
1. Create the Widget Extension in Xcode:
- Open your project's
iosfolder in Xcode (open ios/HealthStatsWidgetApp.xcworkspace). - Go to
File > New > Target.... - Select "Widget Extension" and click "Next".
- Name your widget (e.g., "HealthStatsWidget") and ensure "Include Configuration Intent" is unchecked for this simple example.
- Click "Finish". Activate the new scheme when prompted.
2. Configure App Groups:
- In Xcode, select your main app target (
HealthStatsWidgetApp). - Go to the "Signing & Capabilities" tab and click "+ Capability" to add "App Groups".
- Click the "+" button under "App Groups" and add a new group with a unique identifier, typically in the format
group.com.yourcompany.yourappname. - Now, select your widget extension target (
HealthStatsWidget) and repeat the same process, adding the exact same App Group.
3. Share Data from React Native to the iOS Widget:
First, install the package for shared preferences:
npm install react-native-shared-group-preferences
cd ios && pod install
Now, in your React Native code (e.g., App.js), you can write data to the shared storage:
// src/App.js
import React from 'react';
import { SafeAreaView, Button, View, Text } from 'react-native';
import SharedGroupPreferences from 'react-native-shared-group-preferences';
const App = () => {
const appGroupIdentifier = 'group.com.yourcompany.HealthStatsWidgetApp'; // Replace with your App Group Identifier
const updateWidgetData = async () => {
const healthData = {
steps: Math.floor(Math.random() * 10000),
sleepScore: Math.floor(Math.random() * 100),
};
try {
await SharedGroupPreferences.setItem('healthData', healthData, appGroupIdentifier);
console.log('Data saved to shared storage');
} catch (error) {
console.error('Error saving data:', error);
}
};
return (
<SafeAreaView>
<View style={{ padding: 20 }}>
<Text style={{ fontSize: 18, marginBottom: 20 }}>Health Stats App</Text>
<Button title="Update Widget Data" onPress={updateWidgetData} />
</View>
</SafeAreaView>
);
};
export default App;
4. Displaying Data in the SwiftUI Widget:
Now, let's modify the widget's Swift code to read and display this data. Open the HealthStatsWidget.swift file in Xcode.
// HealthStatsWidget/HealthStatsWidget.swift
import WidgetKit
import SwiftUI
struct HealthData: Codable {
let steps: Int
let sleepScore: Int
}
struct Provider: TimelineProvider {
let appGroupIdentifier = "group.com.yourcompany.HealthStatsWidgetApp" // Replace with your App Group Identifier
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), healthData: HealthData(steps: 8000, sleepScore: 85))
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), healthData: HealthData(steps: 8000, sleepScore: 85))
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let userDefaults = UserDefaults(suiteName: appGroupIdentifier)
var entries: [SimpleEntry] = []
if let savedData = userDefaults?.object(forKey: "healthData") as? [String: Any] {
let steps = savedData["steps"] as? Int ?? 0
let sleepScore = savedData["sleepScore"] as? Int ?? 0
let entry = SimpleEntry(date: Date(), healthData: HealthData(steps: steps, sleepScore: sleepScore))
entries.append(entry)
} else {
let entry = SimpleEntry(date: Date(), healthData: HealthData(steps: 0, sleepScore: 0))
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
struct SimpleEntry: TimelineEntry {
let date: Date
let healthData: HealthData
}
struct HealthStatsWidgetEntryView : View {
var entry: Provider.Entry
var body: some View {
VStack {
Text("Health Stats")
.font(.headline)
Text("Steps: \(entry.healthData.steps)")
Text("Sleep Score: \(entry.healthData.sleepScore)")
}
}
}
@main
struct HealthStatsWidget: Widget {
let kind: String = "HealthStatsWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
HealthStatsWidgetEntryView(entry: entry)
}
.configurationDisplayName("Health Stats")
.description("Your daily health stats.")
}
}
How it works
- App Groups create a secure, shared container on the device that both your main app and its extensions can access.
react-native-shared-group-preferencesprovides a bridge to the nativeUserDefaults(for iOS), allowing us to write data to this shared container from JavaScript.- The
TimelineProviderin our Swift code is responsible for fetching the data and determining when the widget should update. - The
getTimelinefunction reads the data fromUserDefaultsusing the same App Group identifier and creates a timeline entry to be displayed by the widget's SwiftUI view.
Common pitfalls
- Ensure the App Group identifier is identical in your main app target, widget extension target, and your React Native code.
- You must have an active Apple Developer account to use App Groups.
- Widget updates are not instantaneous and are managed by the OS to conserve battery.
Step 2: Building the Android Widget
What we're doing
For Android, we'll use Android Studio to create an AppWidgetProvider and a layout for our widget in XML. To share data, we will create a custom native module that utilizes SharedPreferences.
Implementation
1. Create the Widget in Android Studio:
- Open the
androidfolder of your project in Android Studio. - Right-click on the
appfolder, then go toNew > Widget > App Widget. - Set the
Class NametoHealthStatsWidgetProviderand theLayout Nametohealth_stats_widget. Click "Finish".
2. Design the Widget Layout:
Android Studio will create health_stats_widget.xml in the res/layout directory. Open it and add the following:
<!-- res/layout/health_stats_widget.xml -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:background="@android:color/white"
android:padding="16dp">
<TextView
android:id="@+id/widget_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Health Stats"
android:textColor="@android:color/black"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_steps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Steps: 0"
android:textColor="@android:color/black" />
<TextView
android:id="@+id/widget_sleep"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Sleep Score: 0"
android:textColor="@android:color/black" />
</LinearLayout>
3. Create a Native Module for Data Sharing:
We need to create a bridge so our React Native app can communicate with the native Android side.
First, create a new Java class SharedStorageModule.java:
// android/app/src/main/java/com/healthstatswidgetapp/SharedStorageModule.java
package com.healthstatswidgetapp;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import android.content.Context;
import android.content.SharedPreferences;
public class SharedStorageModule extends ReactContextBaseJavaModule {
ReactApplicationContext context;
public SharedStorageModule(ReactApplicationContext reactContext) {
super(reactContext);
context = reactContext;
}
@Override
public String getName() {
return "SharedStorage";
}
@ReactMethod
public void set(String data) {
SharedPreferences.Editor editor = context.getSharedPreferences("DATA", Context.MODE_PRIVATE).edit();
editor.putString("healthData", data);
editor.apply();
}
}
Next, create a package to register this module, SharedStoragePackage.java:
// android/app/src/main/java/com/healthstatswidgetapp/SharedStoragePackage.java
package com.healthstatswidgetapp;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class SharedStoragePackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new SharedStorageModule(reactContext));
return modules;
}
}
Finally, register the package in MainApplication.java:
// android/app/src/main/java/com/healthstatswidgetapp/MainApplication.java
// ...
import com.healthstatswidgetapp.SharedStoragePackage; // Add this import
public class MainApplication extends Application implements ReactApplication {
// ...
@Override
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
// Packages that cannot be autolinked yet can be added manually here, for example:
packages.add(new SharedStoragePackage()); // Add this line
return packages;
}
// ...
}
4. Update the Widget Provider to Display Data:
Modify HealthStatsWidgetProvider.java to read from SharedPreferences and update the widget's views.
// android/app/src/main/java/com/healthstatswidgetapp/HealthStatsWidgetProvider.java
package com.healthstatswidgetapp;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.SharedPreferences;
import android.widget.RemoteViews;
import org.json.JSONObject;
public class HealthStatsWidgetProvider extends AppWidgetProvider {
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId) {
try {
SharedPreferences sharedPref = context.getSharedPreferences("DATA", Context.MODE_PRIVATE);
String healthDataString = sharedPref.getString("healthData", "{\"steps\":0,\"sleepScore\":0}");
JSONObject healthData = new JSONObject(healthDataString);
// Construct the RemoteViews object
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.health_stats_widget);
views.setTextViewText(R.id.widget_steps, "Steps: " + healthData.getInt("steps"));
views.setTextViewText(R.id.widget_sleep, "Sleep Score: " + healthData.getInt("sleepScore"));
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
// There may be multiple widgets active, so update all of them
for (int appWidgetId : appWidgetIds) {
updateAppWidget(context, appWidgetManager, appWidgetId);
}
}
}
5. Send Data from React Native to the Android Widget:
Now, let's update our React Native code to use the new native module for Android.
// src/App.js (with Android additions)
import React from 'react';
import { SafeAreaView, Button, View, Text, NativeModules, Platform } from 'react-native';
import SharedGroupPreferences from 'react-native-shared-group-preferences';
const { SharedStorage } = NativeModules;
const App = () => {
const appGroupIdentifier = 'group.com.yourcompany.HealthStatsWidgetApp'; // For iOS
const updateWidgetData = async () => {
const healthData = {
steps: Math.floor(Math.random() * 10000),
sleepScore: Math.floor(Math.random() * 100),
};
if (Platform.OS === 'ios') {
try {
await SharedGroupPreferences.setItem('healthData', healthData, appGroupIdentifier);
console.log('iOS Data saved to shared storage');
} catch (error) {
console.error('Error saving iOS data:', error);
}
} else if (Platform.OS === 'android') {
try {
await SharedStorage.set(JSON.stringify(healthData));
console.log('Android Data saved to shared storage');
} catch (error) {
console.error('Error saving Android data:', error);
}
}
};
return (
<SafeAreaView>
<View style={{ padding: 20 }}>
<Text style={{ fontSize: 18, marginBottom: 20 }}>Health Stats App</Text>
<Button title="Update Widget Data" onPress={updateWidgetData} />
</View>
</SafeAreaView>
);
};
export default App;
How it works
- We created a custom Native Module
SharedStoragethat exposes asetmethod to our JavaScript code. - This method uses Android's
SharedPreferencesto store the health data as a JSON string. - The
HealthStatsWidgetProvideris anAppWidgetProvider, which is a broadcast receiver that handles widget updates. - In the
onUpdatemethod, we read the data fromSharedPreferences, parse the JSON, and useRemoteViewsto update the text in our widget's layout.
Putting It All Together
After implementing the steps for both iOS and Android, you can run your app on both platforms.
Testing Steps:
- Run the app on your simulator or device:
- For iOS:
npx react-native run-ios - For Android:
npx react-native run-android
- For iOS:
- Open the app and press the "Update Widget Data" button.
- Go to your home screen and add the "Health Stats" widget.
- You should see the health data displayed on your widget.
- Go back to the app, press the button again, and the widget should update (note: widget updates on Android can have a slight delay).
Security Best Practices
- Sensitive Data: For this example, we're using simple, non-sensitive data. If you need to display sensitive information, consider encrypting the data before storing it in shared preferences.
- Data Validation: Always validate the data on the native side before displaying it in the widget to prevent crashes from malformed data.
Conclusion
You've successfully built a cross-platform home screen widget for your React Native application! While it requires diving into native code, the process is manageable and unlocks a powerful way to keep your users engaged. We've seen how to create native UI for widgets on both iOS and Android and how to establish a data bridge to share information from your React Native app.
Next Steps for Readers:
- Explore different widget sizes and layouts.
- Implement background fetching to update the widget periodically without user interaction.
- Add deep linking so that tapping the widget opens a specific screen in your app.