Learn how to create a home screen widget for Android using React native expo and sync seamlessly with its parent app using shared preferences and a custom Expo module.
Problem Statement
Creating a widget in Expo-based React Native can be tricky. While the Expo makes app development easier by hiding much of the native complexity, widgets often need native access to the device operating system, which the Expo does not fully support outside the box.
To make the widget work with an Expo-based app, you need to write custom native code (i.e., Java or Kotlin for Android), which means moving beyond Expo’s standard setup. You'll need to do some extra setup, like using the Expo Development Client and dealing with possible build issues. Basically, since widgets rely on native code, they don't exactly play nicely with Expo out of the box.
Objective
To implement interactive widgets within an Expo-managed React Native app, which:
- Create a widget in Android that displays an image.
- The state is synchronised with its parent app using shared storage.
Step 1: Create an Expo app
Run the following command to create the Expo app:
npx create-expo-app@latest myPhotoApp
Create a UI to display that:
- Text field to accept the image URL.
- Button that submits the image URL to the image view.
- Image view to render the images provided in the URL.
Step 2: Create Widget
Expo does not give access to native code out of the box, so we need to run the command to eject the project for native code access.
npx expo prebuild
Once the command is successfully executed, we will have two folders, android
and ios
. We will be ignoring ios
folder for now as we are focused on creating a widget for android
Open the Android folder in Android Studio. Once the build is successful, right click on res > New > Widget > App Widget
Configure your widget, and click finish.
A new file will be created, but we will only focus on three files: MyWidget.java
inside the app
folder, my_widget.xml
inside res/layout
, and my_widget_info.xml
inside res/xml
.
-
`MyWidget
extends the
AppWidgetProvider`, where we will code the widget behavior. -
my_widget.xml
is where we will be implementing the widget UI components. -
my_widget_info.xml
contains the preview UI of the widget.
We need to customize the UI of the widget to render the image from the URL provided in the parent app. So we will be modifying my_widget.xml
to meet our objective.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<RelativeLayout
android:id="@+id/widget_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp">
<!-- Image View (main content) -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_container_2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="black">
<ImageView
android:id="@+id/appwidget_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:visibility="gone" />
<LinearLayout
android:id="@+id/fallback_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="10dp">
<ImageView
android:id="@+id/placeholder_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:maxWidth="48dp"
android:maxHeight="48dp"
android:src="@android:drawable/ic_menu_camera" />
<TextView
android:id="@+id/placeholder_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="No Photo"
android:textColor="@android:color/darker_gray"
android:textSize="14dp"
android:textStyle="bold" />
</LinearLayout>
</FrameLayout>
</RelativeLayout>
</LinearLayout>
Run the application in Android Studio, and you can add the widget in the emulator.
Note: For every change you make to the widget, you need to rebuild in Android Studio and restart the Expo development server
3. Create a channel between Widget and react native
As we have configured the Widget UI, we must create a channel between the widget and the React native app to communicate with the widget. We will do this via a shared storage between the widget and the react native app. React native can't access shared storage directly, so we will create a native module to help react native access the shared storage to save the image URL.
Create two files:
-
RNShared: This will extend the
ReactContextBaseJavaModule
for a native module to save the image URL.
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Intent;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import com.facebook.react.bridge.Callback;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import android.content.Context;
public class RNShared extends ReactContextBaseJavaModule {
ReactApplicationContext context;
public RNShared(ReactApplicationContext reactContext) {
super(reactContext);
context = reactContext;
}
@NonNull
@Override
public String getName() {
return "RNShared";
}
@ReactMethod
public void setData(String key, String data, Callback callback) {
SharedPreferences.Editor editor = context.getSharedPreferences("DATA", Context.MODE_PRIVATE).edit();
editor.putString(key, data);
editor.commit();
Intent intent = new Intent(getCurrentActivity().getApplicationContext(), MyWidget.class);
intent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE);
int[] ids = AppWidgetManager.getInstance(getCurrentActivity().getApplicationContext()).getAppWidgetIds(new ComponentName(getCurrentActivity().getApplicationContext(), MyWidget.class));
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids);
getCurrentActivity().getApplicationContext().sendBroadcast(intent);
}
}
- RNPackages: We will be adding
RNShared
as a native module
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 RNPackages 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 RNShared(reactContext));
return modules;
}
}
RNPackages
cannot be autolinked, so we must manually add them to the MainApplication
.
In getPackages()
function add RNPackages()
override fun getPackages(): List<ReactPackage> {
val packages = PackageList(this).packages
packages.add(RNPackages()) // add this
return packages
}
On the React native side, let's import the native module.
import { NativeModules } from 'react-native';
const RNShared = NativeModules.RNShared;
We want to save the image URL when the Submit
button is pressed, so we will implement the setData
method of RNShared
to save the URL.
const onSubmit = () => {
setImageUrl(value); //to set image url for image view;
RNShared.setData("sharedImageUrl", value,()=>{}); // set image url to shared storage
};
4. Render image to Widget
Ok, now the fun part begins. Let us modify mywidget.java
to render the image from the URL saved in shared storage.
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Build;
import android.os.Bundle;
import android.util.TypedValue;
import android.view.View;
import android.widget.RemoteViews;
import java.net.URL;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
/**
* Implementation of App Widget functionality.
*/
public class MyWidget extends AppWidgetProvider {
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
int appWidgetId) {
SharedPreferences sharedPref = context.getSharedPreferences("DATA", Context.MODE_PRIVATE);
String imageURL = sharedPref.getString("sharedImageUrl", "");
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.my_widget);
new Thread(() -> {
Bitmap bitmap = getBitmapFromURL(imageURL);
if (bitmap != null) {
views.setViewVisibility(R.id.appwidget_image, View.VISIBLE);
views.setViewVisibility(R.id.fallback_layout, View.GONE);
Bitmap resizedBitmap = resizeBitmap(bitmap, 300, 200);
views.setImageViewBitmap(R.id.appwidget_image, resizedBitmap);
} else {
views.setViewVisibility(R.id.appwidget_image, View.GONE);
views.setViewVisibility(R.id.fallback_layout, View.VISIBLE);
}
appWidgetManager.updateAppWidget(appWidgetId, views);
}).start();
}
private static Bitmap getBitmapFromURL(String src) {
try {
URL url = new URL(src);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoInput(true);
connection.connect();
InputStream input = connection.getInputStream();
return BitmapFactory.decodeStream(input);
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
@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);
}
}
@Override
public void onEnabled(Context context) {
// Enter relevant functionality for when the first widget is created
}
@Override
public void onDisabled(Context context) {
// Enter relevant functionality for when the last widget is disabled
}
@Override
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
updateWidget(context, appWidgetManager, appWidgetId, newOptions);
}
private void updateWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle options) {
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.my_widget);
int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH);
int minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT);
int iconSizeDp;
int textSizeSp;
if (minWidth < 120 || minHeight < 120) {
iconSizeDp = 32;
textSizeSp = 10;
} else if (minWidth < 200 || minHeight < 200) {
iconSizeDp = 48;
textSizeSp = 12;
} else {
iconSizeDp = 64;
textSizeSp = 14;
}
// Update image size
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
views.setViewLayoutWidth(R.id.placeholder_icon, dpToPx(context, iconSizeDp),TypedValue.COMPLEX_UNIT_SP);
views.setViewLayoutHeight(R.id.placeholder_icon, dpToPx(context, iconSizeDp),TypedValue.COMPLEX_UNIT_SP);
}
// Set text size
views.setTextViewTextSize(R.id.placeholder_text, TypedValue.COMPLEX_UNIT_SP, textSizeSp);
appWidgetManager.updateAppWidget(appWidgetId, views);
}
private int dpToPx(Context context, int dp) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp,
context.getResources().getDisplayMetrics());
}
}
Image URL cannot be directly rendered in native, like we have been doing in React native, so we will be converting the image from URL to a bitmap so we can render the image in our widget. getBitmapFromURL()
will convert the image from the URL to a bitmap.
Finally, we have everything ready:
- Widget
- Widget UI
- Channel for the communication between React native and the widget.
Let’s try to rebuild the application in Android Studio and rerun the Expo development server to implement changes in the emulator.
Conclusion
While React Native doesn’t directly support Android widgets, you can still implement them natively. This hybrid approach lets you keep your main app in Expo/RN while supporting widgets the Android way.
Here is the link to source code.
Top comments (0)