lit-and-flutter について
Lit and Flutter は、Lit の Web コンポーネントを Flutter のウィジェットツリーに組み込む方法を紹介するチュートリアルです。Web UI と Flutter のデバイス API を組み合わせ、iOS・Android 向けアプリを作る開発者を対象としています。
主な機能
- 01Flutter への Lit コンポーネント埋め込み
- 02Web イベントから Flutter API を呼び出し
- 03iOS・Android 向けビルド
- 04更新された Web コンポーネントコードの読み込み
- 05初回ダウンロード後のオフライン利用
活用シーン
- 01既存の Web アプリを Flutter でラップ
- 02Web イベントからアプリ内購入 API を呼び出し
- 03Web UI にネイティブ機能を追加
- 04ハイブリッドモバイルアプリの構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ rodydavis/ skills --skill lit-and-flutterインストール後、Claude Code で/lit-and-flutterを入力して読み込まれたか確認します。
詳細
Lit と Flutter
この記事では、Lit の Web コンポーネントを設定し、Flutter のウィジェットツリー内でインラインに使用する方法を説明します。
TLDR 最終的なソースコードはこちらで確認できます。
この連携を使う理由は、既存の Web アプリ、またはその一部分だけをウィジェットツリーに埋め込めることです。
Flutter でラップすることで、Web コンポーネントのイベントリスナーからデバイス API を呼び出せます。
たとえば、購入を処理するアプリがある場合、アプリ内購入 API や、Web では利用できないその他のデバイス固有の機能を呼び出せるようになります。
また、Google Play と App Store の両方に配信できるクロスプラットフォームアプリも実現できます。
Web コンポーネントにはサイトを更新するたびに新しいコードが反映されるため、Web コンポーネントが変更されるたびにアップデートを配信する必要はありません。
前提条件
- Flutter SDK
- Xcode と Command Line Tools
- Android SDK
- Vscode
- Node
- Typescript
はじめに
まず空のディレクトリを作成し、好きな名前を snake_case で付けます。
mkdir flutter_lit_example
cd flutter_lit_example
Web のセットアップ
ここでは flutter_lit_example ディレクトリに移動し、Flutter と Lit をセットアップします。まずは Node から始めましょう。
npm init -y
npm i lit
npm i -D typescript vite @types/node
これにより、Node プロジェクトの基本設定を行い、必要なパッケージをインストールします。次に、設定ファイルを追加しましょう。
touch tsconfig.json
touch vite.config.ts
これにより、ファイルが 2 つ作成されます。次に tsconfig.json を開き、以下を貼り付けます。
{
"compilerOptions": {
"module": "esnext",
"lib": [
"es2017",
"dom",
"dom.iterable"
],
"types": [
"vite/client"
],
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "./types",
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*.ts"
],
"exclude": []
}
これは基本的な TypeScript 設定です。次に vite.config.ts を開き、以下を貼り付けます。
import { defineConfig } from "vite";
import { resolve } from "path";
// https://vitejs.dev/config/
export default defineConfig({
base: "/flutter_lit_example/", // TODO: Name of your github repo
build: {
outDir: "build/web",
rollupOptions: {
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: `assets/[name].[ext]`,
},
input: {
main: resolve(__dirname, "index.html"),
// TODO: Create a new module for each component you want to embed
},
},
},
});
次に、Web コンポーネントを作成します。
mkdir src
cd src
touch my-app.ts
cd ..
my-app.ts を開き、以下を貼り付けます。
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("my-app")
export class MyApp extends LitElement {
static styles = css`
p {
color: blue;
}
`;
@property()
name = "Somebody";
render() {
return html`<div>
<p>Hello, ${this.name}!</p>
<slot></slot>
</div>`;
}
}
Web アプリ用に index.html を作成する必要があります。
touch index.html
index.html を開き、以下を貼り付けます。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Example</title>
<script type="module" src="/src/my-app.ts"></script>
<style>
body {
padding: 0;
margin: 0;
}
my-app {
width: 100%;
height: 100vh;
}
</style>
</head>
<body>
<my-app></my-app>
</body>
</html>
Flutter のセットアップ
Web の基本設定ができたので、Flutter に進みましょう。以下のコマンドでプロジェクトを作成します。
flutter create --platforms=ios,android .
flutter packages get
pubspec.yaml を開き、以下の内容に更新します。
name: flutter_lit_example
description: A hybrid Flutter app.
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=2.7.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
flutter_inappwebview: ^5.3.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
パッケージを再度取得してください。
flutter packages get
次に、Web コンポーネントをラップするファイルを作成します。
cd lib
touch web_component.dart
cd ..
web_component.dart を開き、以下を貼り付けます。
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
class WebComponent extends StatefulWidget {
const WebComponent({
Key key,
@required this.name,
@required this.bundle,
this.attributes = const {},
this.slot = '',
this.events = const [],
}) : super(key: key);
final String name, bundle;
final Map<String, String> attributes;
final String slot;
final List<EventCallback> events;
@override
_WebComponentState createState() => _WebComponentState();
}
class _WebComponentState extends State<WebComponent> {
InAppWebViewController controller;
final Map<String, List<EventCallback>> _events = {};
String get source {
return '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body {
padding: 0;
margin: 0;
}
${widget.name} {
width: 100%;
height: 100vh;
}
</style>
<script type="module" crossorigin src="${widget.bundle}"></script>
</head>
<body>
<${widget.name} ${widget.attributes.entries.map((e) => '${e.key}="${e.value}"').join(' ')}>
${widget.slot}
</${widget.name}>
<script>
window.addEventListener("flutterInAppWebViewPlatformReady", (event) => {
${widget.events.join('\n')}
});
</script>
</body>
</html>
''';
}
void _setup(InAppWebViewController controller) {
this.controller = controller;
this._setupEvents();
}
void _setupEvents() {
for (final event in _events.keys) {
controller.removeJavaScriptHandler(handlerName: event);
}
for (final event in widget.events) {
_addEvent(event);
}
}
void _addEvent(EventCallback event) {
controller.addJavaScriptHandler(
handlerName: event.query,
callback: event.onPressed,
);
_events[event.event] ??= [];
_events[event.event].add(event);
}
@override
void didUpdateWidget(covariant WebComponent oldWidget) {
if (oldWidget.events != widget.events) {
_setupEvents();
}
if (oldWidget.slot != widget.slot ||
oldWidget.bundle != widget.bundle ||
oldWidget.name != widget.name) {
controller.loadData(data: source);
}
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return InAppWebView(
initialData: InAppWebViewInitialData(data: source),
onWebViewCreated: _setup,
);
}
}
class EventCallback {
EventCallback({
@required this.onPressed,
@required this.event,
this.query,
});
final String query, event;
final dynamic Function(List<dynamic> args) onPressed;
@override
String toString() => _source;
String get _prefix => query != null && query.isNotEmpty
? 'document.querySelector("$query")'
: 'document.body';
String get _source => [
'$_prefix.addEventListener("$event", (e) => {',
' window.flutter_inappwebview.callHandler("$query", e);',
'}, false);',
].join('\n');
}
main.dart を開き、以下を貼り付けます。
import 'package:flutter/material.dart';
import 'web_component.dart';
const WEBSITE_URL = 'https://rodydavis.github.io/flutter_lit_example/';
const BUNDLE_PATH = 'assets/main.js';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final title = 'Flutter Hybrid App';
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: title,
theme: ThemeData(primarySwatch: Colors.blue),
home: MyHomePage(title: title),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Builder(
builder: (context) => WebComponent(
name: 'my-app',
bundle: '$WEBSITE_URL/$BUNDLE_PATH',
attributes: {
'name': widget.title,
},
slot: '<button id="my-button">Talk back!</button>',
events: [
EventCallback(
event: 'click',
query: '#my-button',
onPressed: (_) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Clicked!')));
},
),
],
),
),
);
}
}
WEBSITE_URL はデプロイ先の Web サイトの URL に、BUNDLE_URL は JS バンドルへの相対パスに更新する必要があります。
これにより、新しいバージョンが公開されたときに自動更新され、キャッシュが古くなります。また、初回ダウンロード後はオフラインでも利用できるようになります。
実行
これでアプリケーションを実行できますが、セットアップにはいくつかの手順が必要です。
Web アプリをローカルでテストしてビルドするには、vite を使って index.html をレンダリングします。
npm i
npm run dev
以下のように表示されます。
vite v2.2.3 dev server running at:
Local: http://localhost:3000/flutter_lit_example/
Network: http://192.168.1.143:3000/flutter_lit_example/
ready in 311ms.
リンク http://localhost:3000/flutter_lit_example/ を開くと、実行中の Web アプリを確認でき、my-app.ts の変更がホットリロードされます。
Lit についてさらに知りたい場合は、こちらのドキュメントをお読みください。
表示に問題がなければ、Flutter に進んでネイティブアプリに組み込みましょう。これにより、アプリ内購入 API やプッシュ通知を利用する場合に、ネイティブコードにアクセスできます。
ターミナルを終了し、以下を実行します。
flutter packages get
flutter build ios
flutter build appbundle
flutter run
これにより、実行中のデバイスが選択されるか、デバイスの選択を求められます。デバイス上で実行されると、Flutter アプリと Web コンポーネントの間で双方向通信できることを確認できます。
まとめ
ソースコードを確認したい場合はこちらをご覧ください。それでは、お読みいただきありがとうございました。ご質問があればぜひお知らせください。