Flutter跨平台剪贴板同步插件super_clipboard_syncme的使用

Flutter跨平台剪贴板同步插件super_clipboard_syncme的使用

特性

  • Flutter全面的剪贴板功能。
  • 支持macOS, iOS, Android, Windows, Linux 和 Web。
  • 平台无关的代码用于读写常见的剪贴板格式。
  • 支持自定义数据格式。
  • 剪贴板项支持多种表示形式。
  • 按需提供剪贴板数据。

Super Clipboard

开始使用

super_clipboard 使用 Rust 在内部实现低级别的平台特定功能。

如果你没有安装 Rust,该插件会自动下载针对目标平台的预编译二进制文件。

如果你想从源代码编译 Rust 代码,可以通过以下命令安装 Rust:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

对于 Windows 用户,可以使用以下安装程序:

https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe

如果你已经安装了 Rust,请确保更新到最新版本:

rustup update

至此,构建集成会自动安装所需的 Rust 目标和其他依赖项(如 NDK)。这也意味着第一次构建可能会花费一些时间。

Android 支持

NDK 是使用 super_clipboard 所必需的。如果未安装,它将在第一次构建时自动安装。NDK 是一个较大的下载文件(约1GB),因此可能需要一些时间来完成安装。

NDK 的版本在你的 Flutter 项目的 android/app/build.gradle 文件中指定:

android {
    // 默认情况下,项目使用来自 flutter 插件的 NDK 版本。
    ndkVersion flutter.ndkVersion
}

如果你有一个较旧的 Flutter Android 项目,你需要在 android/app/build.gradle 中指定一个合理的新版本最小 SDK 版本:

android {
    defaultConfig {
        minSdkVersion 23
}

为了能够在 Android 剪贴板上写入图像和其他自定义数据,你需要在 AndroidManifest.xml 中声明一个内容提供器:

<manifest>
    <application>
        ...
        <provider
            android:name="com.superlist.super_native_extensions.DataProvider"
            android:authorities="&lt;your-package-name&gt;.SuperClipboardDataProvider"
            android:exported="true"
            android:grantUriPermissions="true" >
        </provider>
        ...
    </application>
</manifest>

请确保将 <your-package-name> 替换为你的实际包名。

使用方法

从剪贴板读取数据

import 'package:super_clipboard/super_clipboard.dart';

// ...

final clipboard = SystemClipboard.instance;
if (clipboard == null) {
    return; // 剪贴板 API 在此平台上不受支持。
}
final reader = await clipboard.read();

if (reader.canProvide(Formats.htmlText)) {
    final html = await reader.readValue(Formats.htmlText);
    // 处理 HTML 文本
}

if (reader.canProvide(Formats.plainText)) {
    final text = await reader.readValue(Formats.plainText);
    // 处理纯文本
}

/// 二进制格式需要作为流读取
if (reader.canProvide(Formats.png)) {
    reader.getFile(Formats.png, (file) {
        // 处理 PNG 图像
        final stream = file.getStream();
    });
}

格式

对于更多内置支持的格式,请参见 Formats 类。

注意,在 Windows 上,剪贴板中的图像通常存储为 DIB 或 DIBv5 格式,而在 macOS 上则常用 TIFF 格式。super_clipboard 会透明地将这些图像暴露为 PNG 格式。

你可以通过 reader.isSynthesized(Formats.png) 查询剪贴板中的 PNG 图像是否是合成的。

写入剪贴板

import 'package:super_clipboard/super_clipboard.dart';

// ...

final clipboard = SystemClipboard.instance;
if (clipboard == null) {
    return; // 剪贴板 API 在此平台上不受支持。
}
final item = DataWriterItem();
item.add(Formats.htmlText('<b>HTML text</b>'));
item.add(Formats.plainText('plain text'));
item.add(Formats.png(imageData));
await clipboard.write([item]);

你也可以按需提供表示形式:

final item = DataWriterItem();
item.add(Formats.htmlText.lazy(() => '<b>HTML text</b>'));
item.add(Formats.plainText.lazy(() => 'plain text'));
item.add(Formats.png.lazy(() => imageData));
await clipboard.write([item]);

如果这样做,请确保回调可以在不产生不必要的延迟的情况下提供所需的数据。在某些平台上,主线程可能在请求数据时被阻塞。此功能用于按需提供替代表示形式。不要 在懒惰回调中开始下载文件或其他不能保证在短时间内完成的操作。对于复制或拖动尚未准备好的文件,请使用 DataWriterItem.addVirtualFile 代替。

在某些平台上,当写入剪贴板时,数据可能被立即请求。在这种情况下,回调将被立即调用。

当写入图像时,首选格式是 PNG。大多数平台都可以原生处理剪贴板中的 PNG 图像。在 Windows 上,PNG 将按需转换为 DIB 和 DIBv5 格式,这是本地应用程序所期望的。

虽然剪贴板 API 支持写入多个项目,但并非所有平台都完全支持这一点。在 Windows 上,超过第一个项目的剪贴板项仅支持 Formats.fileUri 类型(因此可以存储多个文件 URI 在剪贴板中),而在 Linux 上,仅支持的附加项格式为 Formats.uriFormats.fileUri

在 Web 端访问剪贴板

浏览器对从剪贴板读取值有一些限制。如果剪贴板中的值是从另一个应用程序复制的,则用户需要确认剪贴板访问权限(通常以弹出框的形式)。默认情况下,Firefox 不支持异步剪贴板 API。

为了避免这种限制,super_clipboard 提供了一种监听浏览器剪贴板事件的方法,当用户在浏览器窗口中按下适当的键盘快捷键或从菜单中选择选项时,该事件会被触发。

通过 paste 事件提供的剪贴板读取器可以在所有浏览器(包括 Firefox)上无限制地访问剪贴板数据,并且还可以读取复制到剪贴板的本地文件内容。

copycut 事件处理器是 Firefox 上写入剪贴板的唯一方式。然而,它们仅限于写入文本数据到剪贴板,并不支持异步提供数据。因此,当 SystemClipboard.instance 在 Web 端非空时,建议使用常规剪贴板 API。

final events = ClipboardEvents.instance;
if (events == null) {
  // 剪贴板事件仅在 Web 端受支持。
  return;
}

events.registerPasteEventListener((event) async {
   // 请求剪贴板读取器将阻止默认粘贴操作,例如将文本插入可编辑元素。
   final reader = await event.getClipboardReader();
   if (reader.canProvide(Formats.htmlText)) {
     final html = await event.clipboardReader.readValue(Formats.htmlText);
     // 处理 HTML 文本
   }
});

events.registerCopyEventListener((event) {
   // 调用 [write] 方法将阻止默认复制操作,例如将选中的文本复制到剪贴板。
   final item = DataWriterItem();
   item.add(Formats.htmlText('<b>HTML text</b>'));
   item.add(Formats.plainText('plain text'));
   event.write([item]);
});

运行示例

示例项目位于 super_clipboard/example

flutter pub global activate melos # 如果你还没有安装 melos
git clone https://github.com/superlistapp/super_native_extensions.git
cd super_native_extensions
melos bootstrap

之后,你可以在 VSCode 中打开文件夹并运行 clipboard_example 启动配置。

TODO(knopp): 添加 IntelliJ 启动配置

其他信息

这个插件处于非常早期的开发阶段,相当实验性。

欢迎提交 PR 和 bug 报告!

示例代码

import 'dart:ui' as ui;
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:super_clipboard/super_clipboard.dart';
import 'package:flutter_layout_grid/flutter_layout_grid.dart';

import 'widget_for_reader.dart';

void main() async {
  runApp(const MyApp());
}

const _notAvailableMessage =
    'Clipboard is not available on this platform. Use ClipboardEvents API instead.';

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // 这个小部件是你应用的根。
  [@override](/user/override)
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SuperClipboard Example',
      theme: ThemeData(
        snackBarTheme: const SnackBarThemeData(
          behavior: SnackBarBehavior.floating,
        ),
        outlinedButtonTheme: OutlinedButtonThemeData(
          style: OutlinedButton.styleFrom(
              padding:
                  const EdgeInsets.symmetric(horizontal: 10, vertical: 16)),
        ),
        primarySwatch: Colors.blue,
        useMaterial3: false,
      ),
      home: const MyHomePage(title: 'SuperClipboard Example'),
    );
  }
}

class Expand extends SingleChildRenderObjectWidget {
  const Expand({super.key, required super.child});

  [@override](/user/override)
  RenderObject createRenderObject(BuildContext context) => _RenderExpanded();
}

class _RenderExpanded extends RenderProxyBox {
  [@override](/user/override)
  void layout(Constraints constraints, {bool parentUsesSize = false}) {
    final boxConstraints = constraints as BoxConstraints;
    super.layout(
        boxConstraints.tighten(
          width: boxConstraints.maxWidth,
          height: boxConstraints.maxHeight,
        ),
        parentUsesSize: parentUsesSize);
  }
}

class HomeLayout extends StatelessWidget {
  const HomeLayout({
    super.key,
    required this.mainContent,
    required this.buttons,
  });

  final List<Widget> mainContent;
  final List<Widget> buttons;

  [@override](/user/override)
  Widget build(BuildContext context) {
    return LayoutBuilder(builder: (context, constraints) {
      if (constraints.maxWidth < 540) {
        return ListView(
          padding: const EdgeInsets.all(16),
          children: [
            LayoutGrid(
              autoPlacement: AutoPlacement.rowDense,
              columnSizes: [1.5.fr, 2.fr],
              rowSizes: const [auto, auto, auto, auto],
              gridFit: GridFit.expand,
              rowGap: 10,
              columnGap: 10,
              children: buttons.map((e) => Expand(child: e)).toList(),
            ),
            const SizedBox(height: 16),
            ...mainContent,
          ],
        );
      } else {
        return Row(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            SingleChildScrollView(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: IntrinsicWidth(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: buttons
                        .intersperse(const SizedBox(height: 10))
                        .toList(growable: false),
                  ),
                ),
              ),
            ),
            VerticalDivider(
              color: Colors.blueGrey.shade100,
              thickness: 1,
              width: 1,
            ),
            Expanded(
              child: ListView(
                padding: const EdgeInsets.all(16),
                children: mainContent,
              ),
            )
          ],
        );
      }
    });
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  [@override](/user/override)
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  void showMessage(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        duration: const Duration(milliseconds: 1500),
      ),
    );
  }

  [@override](/user/override)
  void initState() {
    super.initState();
    ClipboardEvents.instance?.registerPasteEventListener(_onPasteEvent);
  }

  [@override](/user/override)
  void dispose() {
    super.dispose();
    ClipboardEvents.instance?.unregisterPasteEventListener(_onPasteEvent);
  }

  void copyText() async {
    final clipboard = SystemClipboard.instance;
    if (clipboard != null) {
      final item = DataWriterItem();
      item.add(Formats.htmlText('<b>This is a <em>HTML</em> value</b>.'));
      item.add(Formats.plainText('This is a plaintext value.'));
      await clipboard.write([item]);
    } else {
      showMessage(_notAvailableMessage);
    }
  }

  void copyTextLazy() async {
    final clipboard = SystemClipboard.instance;
    if (clipboard != null) {
      final item = DataWriterItem();
      item.add(Formats.htmlText.lazy(() {
        showMessage('Lazy rich text requested.');
        return '<b>This is a <em>HTML</em> value</b> generated <u>on demand</u>.';
      }));
      item.add(Formats.plainText.lazy(() {
        showMessage('Lazy plain text requested.');
        return 'This is a plaintext value generated on demand.';
      }));
      await clipboard.write([item]);
    } else {
      showMessage(_notAvailableMessage);
    }
  }

  void copyImage() async {
    final clipboard = SystemClipboard.instance;
    if (clipboard != null) {
      final image = await createImageData(Colors.red);
      final item = DataWriterItem(suggestedName: 'RedCircle.png');
      item.add(Formats.png(image));
      await clipboard.write([item]);
    } else {
      showMessage(_notAvailableMessage);
    }
  }

  void copyImageLazy() async {
    final clipboard = SystemClipboard.instance;
    if (clipboard != null) {
      final item = DataWriterItem(suggestedName: 'BlueCircle.png');
      item.add(Formats.png.lazy(() {
        showMessage('Lazy image requested.');
        return createImageData(Colors.blue);
      }));
      await clipboard.write([item]);
    } else {
      showMessage(_notAvailableMessage);
    }
  }

  void copyCustomData() async {
    final clipboard = SystemClipboard.instance;
    if (clipboard != null) {
      final item = DataWriterItem();
      item.add(formatCustom(Uint8List.fromList([1, 2, 3, 4])));
      await clipboard.write([item]);
    } else {
      showMessage(_notAvailableMessage);
    }
  }

  void copyCustomDataLazy() async {
    final clipboard = SystemClipboard.instance;
    if (clipboard != null) {
      final item = DataWriterItem();
      item.add(formatCustom.lazy(() async {
        showMessage('Lazy custom data requested.');
        return Uint8List.fromList([1, 2, 3, 4, 5, 6]);
      }));
      await clipboard.write([item]);
    } else {
      showMessage(_notAvailableMessage);
    }
  }

  void copyUri() async {
    final clipboard = SystemClipboard.instance;
    if (clipboard != null) {
      final item = DataWriterItem();
      item.add(Formats.uri(NamedUri(
          Uri.parse('https://github.com/superlistapp/super_native_extensions'),
          name: 'Super Native Extensions')));
      await clipboard.write([item]);
    } else {
      showMessage(_notAvailableMessage);
    }
  }

  void _paste(ClipboardReader reader) async {
    final readers = await Future.wait(
      reader.items.map((e) => ReaderInfo.fromReader(e)),
    );
    if (!mounted) {
      return;
    }
    buildWidgetsForReaders(context, readers, (widgets) {
      setState(() {
        contentWidgets = widgets;
      });
    });
  }

  void _onPasteEvent(ClipboardReadEvent event) async {
    _paste(await event.getClipboardReader());
  }

  var contentWidgets = [];

  [@override](/user/override)
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: HomeLayout(
        mainContent: contentWidgets
            .intersperse(const SizedBox(height: 10))
            .toList(growable: false),
        buttons: [
          OutlinedButton(
            onPressed: copyText,
            child: const Text('Copy Text'),
          ),
          OutlinedButton(
              onPressed: copyTextLazy, child: const Text('Copy Text - Lazy')),
          OutlinedButton(onPressed: copyImage, child: const Text('Copy Image')),
          OutlinedButton(
              onPressed: copyImageLazy, child: const Text('Copy Image - Lazy')),
          OutlinedButton(
              onPressed: copyCustomData, child: const Text('Copy Custom')),
          OutlinedButton(
              onPressed: copyCustomDataLazy,
              child: const Text('Copy Custom - Lazy')),
          OutlinedButton(onPressed: copyUri, child: const Text('Copy URI')),
          OutlinedButton(
              onPressed: () async {
                final clipboard = SystemClipboard.instance;
                if (clipboard != null) {
                  final reader = await clipboard.read();
                  _paste(reader);
                } else {
                  showMessage(_notAvailableMessage);
                }
              },
              style: OutlinedButton.styleFrom(
                backgroundColor: Colors.blue.shade600,
                foregroundColor: Colors.white,
              ),
              child: const Text('Paste')),
        ],
      ),
    );
  }
}

Future<Uint8List> createImageData(Color color) async {
  final recorder = ui.PictureRecorder();
  final canvas = Canvas(recorder);
  final paint = Paint()..color = color;
  canvas.drawOval(const Rect.fromLTWH(0, 0, 200, 200), paint);
  final picture = recorder.endRecording();
  final image = await picture.toImage(200, 200);
  final data = await image.toByteData(format: ui.ImageByteFormat.png);
  return data!.buffer.asUint8List();
}

更多关于Flutter跨平台剪贴板同步插件super_clipboard_syncme的使用的实战教程也可以访问 https://www.itying.com/category-92-b0.html

1 回复

更多关于Flutter跨平台剪贴板同步插件super_clipboard_syncme的使用的实战系列教程也可以访问 https://www.itying.com/category-92-b0.html


super_clipboard_syncme 是一个 Flutter 插件,用于在跨平台应用中实现剪贴板的同步。它支持 Android、iOS、Windows、macOS 和 Linux 平台。通过这个插件,你可以在不同设备之间同步剪贴板内容,确保用户在一个设备上复制的内容可以在另一个设备上粘贴。

以下是如何使用 super_clipboard_syncme 插件的步骤:

1. 添加依赖

首先,在 pubspec.yaml 文件中添加 super_clipboard_syncme 插件的依赖:

dependencies:
  flutter:
    sdk: flutter
  super_clipboard_syncme: ^1.0.0  # 请使用最新版本

然后运行 flutter pub get 来获取依赖。

2. 导入插件

在你的 Dart 文件中导入 super_clipboard_syncme 插件:

import 'package:super_clipboard_syncme/super_clipboard_syncme.dart';

3. 初始化插件

在使用插件之前,需要先初始化它。通常你可以在 main 函数中进行初始化:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await SuperClipboardSyncMe.initialize();
  runApp(MyApp());
}

4. 使用剪贴板同步功能

super_clipboard_syncme 插件提供了 SuperClipboardSyncMe 类来管理剪贴板的同步。你可以使用它来监听剪贴板的变化,并将内容同步到其他设备。

监听剪贴板变化

你可以监听剪贴板内容的变化,并在内容发生变化时执行一些操作:

SuperClipboardSyncMe.onClipboardChanged.listen((ClipboardData data) {
  print('Clipboard content changed: ${data.text}');
});

获取剪贴板内容

你可以获取当前剪贴板的内容:

ClipboardData? data = await SuperClipboardSyncMe.getClipboardData();
if (data != null) {
  print('Current clipboard content: ${data.text}');
}

设置剪贴板内容

你可以将文本或富文本内容设置到剪贴板:

await SuperClipboardSyncMe.setClipboardData(ClipboardData(text: 'Hello, World!'));

5. 处理跨平台同步

super_clipboard_syncme 插件会自动处理跨平台的剪贴板同步。你只需要确保设备之间已经建立了连接(例如通过蓝牙、Wi-Fi 或互联网),插件会自动将剪贴板内容同步到其他设备。

6. 处理异常

在实际使用中,可能会遇到一些异常情况,例如网络问题或权限问题。你需要捕获这些异常并进行适当的处理:

try {
  await SuperClipboardSyncMe.setClipboardData(ClipboardData(text: 'Hello, World!'));
} catch (e) {
  print('Failed to set clipboard data: $e');
}

7. 权限处理

在某些平台上,访问剪贴板可能需要特定的权限。你需要在应用的配置文件中声明这些权限,并在运行时请求用户授权。

Android

AndroidManifest.xml 中添加以下权限:

<uses-permission android:name="android.permission.READ_CLIPBOARD" />
<uses-permission android:name="android.permission.WRITE_CLIPBOARD" />

iOS

Info.plist 中添加以下键值对:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>We need access to Bluetooth to sync clipboard content.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>We need access to Bluetooth to sync clipboard content.</string>

8. 示例代码

以下是一个完整的示例代码,展示了如何使用 super_clipboard_syncme 插件来实现剪贴板的同步:

import 'package:flutter/material.dart';
import 'package:super_clipboard_syncme/super_clipboard_syncme.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await SuperClipboardSyncMe.initialize();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  [@override](/user/override)
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Super Clipboard SyncMe Example'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ElevatedButton(
                onPressed: () async {
                  await SuperClipboardSyncMe.setClipboardData(ClipboardData(text: 'Hello, World!'));
                  print('Clipboard content set to: Hello, World!');
                },
                child: Text('Set Clipboard Text'),
              ),
              SizedBox(height: 20),
              ElevatedButton(
                onPressed: () async {
                  ClipboardData? data = await SuperClipboardSyncMe.getClipboardData();
                  if (data != null) {
                    print('Current clipboard content: ${data.text}');
                  } else {
                    print('Clipboard is empty');
                  }
                },
                child: Text('Get Clipboard Text'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
回到顶部