DEV Community

Cover image for How to build a widget for Android using Expo-based React native
Prajwal Bhandari
Prajwal Bhandari

Posted on

How to build a widget for Android using Expo-based React native

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:

  1. Create a widget in Android that displays an image.
  2. 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
Enter fullscreen mode Exit fullscreen mode

Create a UI to display that:

  1. Text field to accept the image URL.
  2. Button that submits the image URL to the image view.
  3. Image view to render the images provided in the URL.

React native UI


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
Enter fullscreen mode Exit fullscreen mode

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

folder structure react native widget

Open the Android folder in Android Studio. Once the build is successful, right click on res > New > Widget > App Widget

android studio react native widget

Configure your widget, and click finish.

react native widget dialogue

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.

  1. `MyWidget extends the AppWidgetProvider`, where we will code the widget behavior.
  2. my_widget.xml is where we will be implementing the widget UI components.
  3. 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>
Enter fullscreen mode Exit fullscreen mode

Run the application in Android Studio, and you can add the widget in the emulator.

react native expo widget gif

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:

  1. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. 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;
    }

}
Enter fullscreen mode Exit fullscreen mode

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
          }
Enter fullscreen mode Exit fullscreen mode

On the React native side, let's import the native module.

import { NativeModules } from 'react-native';
const RNShared = NativeModules.RNShared;
Enter fullscreen mode Exit fullscreen mode

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
  };
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Widget
  2. Widget UI
  3. 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.

React native widget working on EXPO

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)