Expo UI における Worklet 統合:SwiftUI と Compose の状態を同期制御

Expo UIがreact-native-workletsと統合され、UIスレッド上でSwiftUIとComposeの状態を同期的に制御できるようになりました。JSの往復通信は不要です。

日本語
コピー
Worklet integration in Expo UI: synchronously controlling SwiftUI and Compose state

SDK 56 で Expo UI が UI runtime worklet とネイティブ統合を果たした。これを可能にしたのが react-native-worklets だ。イベントコールバックを UI スレッド上で同期的に実行し、UI の状態も同期的に制御できるようになった。つまり、こう書ける:

import { Host, TextInput, useNativeState } from '@expo/ui';

export default function Screen() {
  const value = useNativeState('');

  return (
    <Host matchContents>
      <TextInput
        value={value}
        placeholder="Type something"
        onChangeText={(value) => {
          'worklet';
          // Runs synchronously on the UI thread, on every keystroke.
          console.log('[UI thread] typed:', value);
        }}
      />
    </Host>
  );
}

注意:動かすにはプロジェクトに react-native-reanimatedreact-native-worklets が必要だ。

裏で起きていること

2つの仕組みが連携している。

useNativeStateObservableState を生成する。これはネイティブ側に存在する SharedObject で、SwiftUI と Compose の両方から監視される。内部的には iOS では ObservableObject、Android では MutableState に対応する。

Worklet コールバックonTextChange など)は、ネイティブビューがイベントを発火したときに UI スレッド上で直接実行される。

この2つが組み合わさると、TextField でキーを1回叩くたびに共有された text の状態が更新され、worklet が走り、SwiftUI と Compose が再レンダリングされる。JS スレッドに飛ぶことは一切ない。

SwiftUI を書いたことがある人なら見覚えがあるはずだ。上の TS コードはほぼ1対1でこう対応する:

struct Screen: View {
  @State var text = ""

  var body: some View {
    TextField("Type something", text: $text)
      .onChange(of: text) { _, newValue in
        print("[UI thread] typed:", newValue)
      }
  }
}

useNativeState@State の役割を果たし、text={text}TextField(text: $text) に相当し、worklet onTextChange.onChange(of:) に対応する。Compose で mutableStateOfonValueChange を使っても構造は同じだ。

ちらつきのない同期入力マスク

この仕組みの最も直接的な恩恵は、入力マスクがようやくまともに動くようになったことだ。worklet はキー入力が届いた同じフレーム内で text.value を書き換えられる。ユーザーがマスク前の文字を見ることはなく、JS スレッドを経由する非同期の往復も発生しない。

次のクレジットカード入力欄は、入力時に UI スレッド上で 42424242424242424242 4242 4242 4242 に整形する:

import { Host, TextInput, useNativeState } from '@expo/ui/swift-ui';

export default function CardNumberField() {
  const value = useNativeState('');

  return (
    <Host matchContents>
      <TextInput
        value={value}
        placeholder="Card number"
        onChangeText={(value) => {
          'worklet';
          const digits = value.replace(/\\D/g, '').slice(0, 16);
          const masked = digits.replace(/(.{4})/g, '$1 ').trim();
          text.value = masked;
        }}
      />
    </Host>
  );
}

動画:blog_video_16x9 — expo.dev で視聴

同じパターンは電話番号、日付、郵便番号、通貨、その他表示テキストを生のキー入力と変えたいあらゆる場面に使える。

Worklet 統合が重要な理由

Worklet 統合によって Expo UI はよりネイティブに近い体験を提供できるようになり、既存の非同期の選択肢に加えて同期という選択肢が生まれた。インタラクションの要件に応じて選べばいい。

入力マスクはその一例にすぎない。同じ「ネイティブ状態 + worklet」のパターンを使えば、Expo UI はネイティブ状態に基づく SwiftUI や Compose の API をさらに多く React Native に持ち込める。

今後どう発展していくのか楽しみだ。

試してみる

Worklet は @expo/ui/swift-ui@expo/ui/jetpack-compose の両方で使え、すでに SDK 56 に入っているTextInput は最初に対応したコンポーネントの1つで、今後のバージョンでより多くのフォームコントロールが同期コールバックを備える予定だ。

出典: Expo Blog← ホームへ戻る