Experimenting with Flutter on Wear OS

Matt Sullivan
5 min readMay 11, 2018

--

Before I started on Flutter here at Google, I worked with Wear, all the way from its 1.0 launch through to 2.0. I’ve got a soft spot for the watch OS and wondered how Flutter could be applied to make writing Wear apps easier.

While Flutter is officially supported on iOS and Android phones, it happily runs on other Android platforms, such as Android for Chrome OS, and Wear OS. Flutter isn’t optimized (yet) for these classes of devices, but it can’t hurt to see how it performs on those platforms.

Wear OS by Google, formally known as Android Wear 2, is essentially full-stack Android, with a set of libraries for watch-specific functionality and form factors. Wear 1.0 forbade making network connections directly from the device, requiring all data to flow through companion apps on the paired phone. Wear OS removed that restriction and it’s now possible to write standalone apps on these devices.

Deploying an existing Flutter app to a watch may work, and Flutter does its best to render the UI within the limited screen space. Flutter’s starter demo app yields surprisingly readable results.

While that’s fine, writing Wear apps requires developers to pay attention to the specifics of the watch: does it have a square or a round watch face, and what are its dimensions; what should the app display when the watch enters ambient mode?

How can Flutter access the Wear APIs, and what patterns can be used to expose said functionality in an elegant, Flutter-like way?

Watch shape

Retrieving screen height and width in Flutter is straightforward using either the WidgetsApp or MaterialApp widget:

final screenSize = MediaQuery.of(context).size;
final screenHeight = screenSize.height;
final screenWidth = screenSize.width;

This doesn’t give us the watch’s shape. Flutter has no way to determine that out of the box. The Wear OS support libraries provide this, and shape can be retrieved easily in Kotlin.

Flutter provides a mechanism to bidirectionally call between Dart and native platform code, using MethodChannels. Functions in either realm can be asynchronously called and simple data types passed back in response. Data can also be streamed using EventChannels.

We can set up a MethodChannel in Android that lets Flutter asynchronously access the shape:

private fun setShapeMethodChannel() {
MethodChannel(flutterView, shapeChannel).setMethodCallHandler { _, result ->
setOnApplyWindowInsetsListener(flutterView, {_, insets: WindowInsetsCompat? ->
if (insets?.isRound == true) {
result.success(0)
}
else {
result.success(1)
}
WindowInsetsCompat(insets)
})
requestApplyInsets(flutterView)
}
}

Then, in Dart, we can call on the channel to fetch the shape:

enum Shape { square, round };Shape shape;
try {
final int result = await platform.invokeMethod('shape');
shape = result == 1 ? Shape.square : Shape.round;
} on PlatformException catch (e) {
// Default to round
print('Error detecting shape: $e');
shape = Shape.round;
}

To make this simple to use in Flutter, I initially created a WatchShape widget that encapsulates the this logic and renders the appropriate widget tree:

Widget build(BuildContext context) => WatchShape(
square: const Text('I\'m a square watch face'),
round: const Text('I\'m a round watch face'),
);

There’s a problem with this approach: both the square and round widget trees are constructed, although only one is used. This is suboptimal and needlessly instantiates widgets that are never used.

Taking a different approach to correct this, I used what the Flutter framework refers to as the builder strategy. Instead of passing a predetermined widget tree into WatchShape using the child property, a builder function is provided:

Widget build(BuildContext context) => WatchShapeBuilder(
builder: (BuildContext context, Shape shape) =>
new Text('I am $shape shaped'));

The builder strategy is simple, yet powerful and Flutter uses it widely in widgets like FutureBuilder and StreamBuilder.

Passing shape down the widget tree

You might not want to build a completely different widget tree for square and round watch faces. You may have some minor UI tweaks, intended for widgets several levels down the tree. To enable this, I created a custom InheritedWidget called InheritedShape:

class InheritedShape extends InheritedWidget {
const InheritedShape({Key key, @required this.shape,
@required Widget child})
: assert(shape != null),
assert(child != null),
super(key: key, child: child);
final Shape shape;static InheritedShape of(BuildContext context) {
return context.inheritFromWidgetOfExactType(InheritedShape);
}
@override
bool updateShouldNotify(InheritedShape old) => shape != old.shape;
}

Shape can now be accessed using:

final shape = InheritedShape.of(context).shape;

You would typically combine this with WatchShape to detect the shape, and then InheritedShape to make it available down the widget tree:

Widget build(BuildContext context) => WatchShape(
builder: (context, shape) => InheritedShape(
shape: shape,
child: ...
));

Ambient mode

For long-running Wear apps, supporting ambient mode is critical. Naively holding a wakelock keeps the screen at full brightness and kills battery life.

Wear provides callbacks that report when the watch enters and exits ambient mode. We can listen for these in Android and use method channels to notify our Flutter code.

We can use AmbientMode.AmbientCallbackProvider and implement each of the callbacks:

class MainActivity: FlutterActivity(), AmbientMode.AmbientCallbackProvider {
private var mAmbientController: AmbientMode.AmbientController? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Set the Flutter ambient callbacks
mAmbientController = AmbientMode.attachAmbientSupport(this)
}
override fun getAmbientCallback(): AmbientMode.AmbientCallback {
return FlutterAmbientCallback(flutterView)
}
}
private class FlutterAmbientCallback(val flutterView: FlutterView): AmbientMode.AmbientCallback() {override fun onEnterAmbient(ambientDetails: Bundle) {
MethodChannel(flutterView, ambientChannel).invokeMethod("enter", null)
super.onEnterAmbient(ambientDetails)
}
override fun onExitAmbient() {
MethodChannel(flutterView, ambientChannel).invokeMethod("exit", null)
super.onExitAmbient()
}
override fun onUpdateAmbient() {
MethodChannel(flutterView, ambientChannel).invokeMethod("update", null)
super.onUpdateAmbient()
}
}

In Flutter, we provide the channel call handler:

@override
initState() {
super.initState();
platformAmbient.setMethodCallHandler((call) {
switch (call.method) {
case 'enter':
setState(() => ambientMode = Mode.ambient);
break;
case 'update':
if (widget.update != null)
widget.update();
else
setState(() => ambientMode = Mode.ambient);
break;
case 'exit':
setState(() => ambientMode = Mode.active);
break;
}
});
}

This functionality can also be encapsulated in a widget. AmbientMode works in much the same manner as WatchShape: the widget takes a builder function that rebuilds when the device changes ambient state:

Widget build(BuildContext context) => AmbientMode(
builder: (context, mode) => mode == Mode.active
? const Text('I\m in active mode')
: const Text('I\'m in ambient mode'),
);

Bringing it together

Combining these widgets is simple, and together they provide the basic foundation for building Wear apps in Flutter:

class WatchScreen extends StatelessWidget {
@override
Widget build(BuildContext context) => WatchShape(
builder: (context, shape) => InheritedShape(
shape: shape,
child: AmbientMode(
builder: (context, mode) =>
mode == Mode.active ? ActiveWatchFace()
: AmbientWatchFace(),
)));
}

WatchScreen detects the shape of the watch and wraps it in InheritedShape. Any shape specific code can now access the shape using InheritedShape.of(context).shape. AmbientMode then builds one of two different widget trees, ActiveWatchFace or AmbientWatchFace, depending on the state. When ambient state changes, the widget tree is automatically rebuilt.

While Flutter isn’t optimized for Wear devices, experimenting on Wear is possible. Flutter’s widget pattern provides an elegant means of handling Wear-specific functionality that lends itself to writing simple, understandable code.

While this has focused on writing Flutter apps on Wear OS, the examples here of communicating between Dart and Kotlin are generally applicable to any Flutter apps interoperating with Android code.

The Wear widgets are available as a plugin , and you can check out the source code in the Flutter Wear Github and example app repos.

--

--