【Flutter】投稿メッセージのURLからリンクプレビューを生成する方法

こんにちは、株式会社Pentagonでアプリ開発をしている中原です。

SNSプラットフォームのTwitterLINEでは、URLを送信するとそのページの内容が一部リンクプレビューとして表示されます。この記事では、そのようなリンクプレビューをウェブページのURLから表示する実装方法を説明します。

【こんな人に読んで欲しい】

  • Flutterアプリ開発エンジニア。
  • ウェブページのURLからリンクプレビューを表示したい方。

【この記事を読むメリット】

  • ウェブページのURLからリンクプレビューを表示する実装方法を学べます。

【結論】
any_link_previewパッケージを追加し、AnyLinkPreview()ウィジェットの引数にURLを指定するだけで、そのページのリンクプレビューを表示することができます。これにより、ユーザーリンクをクリックする前に、その内容の一部を確認できます。
このパッケージは、指定されたURLからメタデータを取得し、それを基にリンクリンクプレビューを生成します。これにより、htmldiourl_launcherなどのパッケージを個別に追加する必要がなくなります。

目次

この記事で触れないこと

この記事では、以下の実装や説明については省略しています。

  • Flutterの開発環境構築
  • AsyncNotifierProviderの説明
  • Freezedの説明

開発環境

  • Flutter 3.10.6
  • Dart 3.0.6
  • vscode

パッケージ

dependencies:
  flutter:
    sdk: flutter
  # any_link_previewを追加
  any_link_preview: ^3.0.1
  flutter_riverpod: ^2.4.0
  freezed_annotation: ^2.4.1
  json_annotation: ^4.8.1
  # 文字列のURLからURL先に遷移するため追加。
  url_launcher: ^6.1.14

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  freezed: ^2.4.2
  build_runner: ^2.4.6
  json_serializable: ^6.7.1

any_link_previewパッケージを追加することで、URLのリンクプレビューを表示できるようになります。さらに、url_launcherを追加することで、文字列のURLから直接URL先に移動することが可能になります。また、AnyLinkPreview()ウィジェットのurlLaunchModeを変更する際にも、url_launcherの追加が必要です。これにより、URLの起動モードを自由に設定することができます。

url_launcherの起動モードには以下の4種類があります。

  1. LaunchMode.inAppWebView:デフォルトで指定されているモードで、遷移先のページをアプリ内の一つのページで開いているかのように遷移します。
  2. LaunchMode.externalApplication:Webページを Safari などの別のアプリケーションで開きます。
  3. LaunchMode.externalNonBrowserApplication:ブラウザ以外の外部のアプリケーションを開く際に使用します。
  4. LaunchMode.platformDefault:プラットフォームのデフォルトの遷移方法を採用します。

使用するメソッドの実装

import 'package:any_link_preview/any_link_preview.dart';

class Utils {
  // URLの正規表現
  static final urlRegExp = RegExp(
      r"((https?:www\.)|(https?:\/\/)|(www\.))[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9]{1,6}(\/[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)?");

  // メッセージからURLを抽出してリストに追加するメソッド
  static List<String> getMathUrlList(String message) {
    final Iterable<Match> urlMatches = urlRegExp.allMatches(message);
    final List<String> urls = urlMatches
        .map((urlMatch) => message.substring(urlMatch.start, urlMatch.end))
        .toList();
    return urls;
  }

  // URLが有効かどうかをチェックするメソッド
  static bool isValidUrl({required String url}) {
    // URLが有効かどうかをチェックする
    final bool isUrlValid = AnyLinkPreview.isValidLink(
      url, // 引数で受け取ったURL
      protocols: ['http', 'https'], // 有効なプロトコルをリストで指定
      hostWhitelist: [], // 有効なURLホストをリストで指定
      hostBlacklist: [], // 無効なURLホストをリストで指定
    );
    return isUrlValid;
  }
}

Messageモデルの実装

import 'package:freezed_annotation/freezed_annotation.dart';

part 'message.freezed.dart';
part 'message.g.dart';

@freezed
class Message with _$Message {
  const factory Message({
    required String text,
    @Default(false) bool showPreview,
  }) = _Message;

  factory Message.fromJson(Map<String, dynamic> json) =>
      _$MessageFromJson(json);
  const Message._();
}

ViewModelの実装

Future<void> addMessage({required String message}) async {
    // stateをローディングにする(値がまだ利用可能でない状態)
    state = const AsyncValue.loading();

    // 1. メッセージからURLを抽出してリストに追加
    final urlList = Utils.getMathUrlList(message);

    // 2. 複数のURLがある場合は、最初のURLを取得
    final url = urlList.isNotEmpty ? urlList.first : '';

    // 3. URLが有効かどうかをチェックする
    final isValidUrl = url.isNotEmpty ? Utils.isValidUrl(url: url) : false;

    // 4. 結果をMessageにセットする
    final result = Message(text: message, url: url, showPreview: isValidUrl);

    // 5. Stateを更新する
    state = await AsyncValue.guard(() async {
      // サーバーに保存しないため、リストに追加
      return [
        ...state.value!,
        result,
      ];
    });
  }
}
  1. 入力されたテキストからURLを抽出し、それをリストに追加する機能を実装しています。これにより、テキスト内のURLを効率的に取り出すことができます。
  2. 複数のURLがリストに存在する場合、最初のURLを取得します。ただし、複数のURLを表示したい場合は、このステップは必要ありません。
  3. 抽出したURLが有効なものであるかどうかを確認するバリデーションを行います。これにより、無効なURLを排除し、有効なURLだけを取り扱うことができます。
  4. 結果をMessageにセットします。
  5. 最後に、Stateを更新します。

Textウィジェットの拡張

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

import 'utils.dart';

// Textウィジェットを拡張
extension TextEx on Text {
  RichText urlToLink(
    BuildContext context,
  ) {
    final textSpans = <InlineSpan>[];
    data?.splitMapJoin(
      Utils.urlRegExp,
      // Utils.urlRegExpの正規表現にマッチした場合
      onMatch: (Match match) {
        final matchUrl = match[0] ?? '';
        textSpans.add(
          TextSpan(
            text: matchUrl,
            // URLの場合は青色にする
            style: const TextStyle(
              color: Colors.blue,
              decoration: TextDecoration.underline,
            ),
            // URLをタップした時の処理 url_launcher
            recognizer: TapGestureRecognizer()
              ..onTap = () async => await launchUrl(
                    Uri.parse(matchUrl),
                  ),
          ),
        );
        return '';
      },
      // Utils.urlRegExpの正規表現にマッチしなかった場合
      onNonMatch: (String text) {
        textSpans.add(
          TextSpan(
            text: text,
            style: const TextStyle(
              color: Colors.black,
            ),
          ),
        );
        return '';
      },
    );
    return RichText(text: TextSpan(children: textSpans));
  }
}

テキストURLを表示するために、Textウィジェットを拡張しています。具体的には、Utils.urlRegExpを使用してテキストを解析し、マッチした部分をURLとして表示します。マッチしない部分は、通常の文字列として表示されます。これにより、テキスト内のURLを適切にハイライトし、ユーザーがそれを認識しやすくなります。

カードコンポーネントの実装

import 'package:any_link_preview/any_link_preview.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';

class CardItem extends StatelessWidget {
  const CardItem({
    super.key,
    required this.url,
  });

  final String url;

  @override
  Widget build(BuildContext context) {
    // UIサイズを変更する場合は、SizedBoxでサイズを指定
    return SizedBox(
      width: 250,
      height: 200,
      child: AnyLinkPreview(
        link: url, // 表示するURL
        backgroundColor: Colors.green[100], // カード title、bodyの背景色
        bodyMaxLines: 2, // 説明文の最大行数を指定
        urlLaunchMode: LaunchMode
            .externalApplication, // URLを開く方法を指定(デフォルトはplatformDefault) 変更する場合はurl_launcherパッケージをインストールする必要がある
        // エラー時のカードの表示ウィジェット
        // errorTitle: , // エラー時のタイトルを指定
        // errorBody: , // エラー時の説明文を指定
        // errorImage: , // エラー時の画像を指定
        // エラー時のカードの表示ウィジェットを指定
        errorWidget: Container(
          color: Colors.grey[300],
          child: const Center(child: Text('エラーが発生しました')),
        ),
      ),
    );
  }
}

AnyLinkPreview()ウィジェットを使用すると、指定したURLリンクプレビューを表示できます。これは自由にカスタマイズ可能なので、ぜひ試してみてください。

画面の実装

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_card_flutter/extention.dart';
import 'package:url_card_flutter/card_item.dart';
import 'package:url_card_flutter/message_async_notifie.dart';

void main() {
  runApp(const ProviderScope(child: MainApp()));
}

class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncValue = ref.watch(messageAsyncNotifierProvider);
    final notifier = ref.watch(messageAsyncNotifierProvider.notifier);
    final TextEditingController messageController = TextEditingController();
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: GestureDetector(
        onTap: () => primaryFocus?.unfocus(),
        child: Scaffold(
          body: asyncValue.when(
            loading: () =>
                const Center(child: CircularProgressIndicator()), // ローディング時の処理
            error: (error, _) =>
                Center(child: Text('Error: $error')), // エラー時の処理
            data: (data) => data.isNotEmpty
                ? ListView.builder(
                    itemCount: data.length,
                    itemBuilder: ((BuildContext context, int index) {
                      final message = data[index];
                      return Padding(
                        padding: const EdgeInsets.symmetric(horizontal: 20),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.end,
                          children: [
                            const SizedBox(
                              height: 10,
                            ),
                            // ----- 入力したメッセージを表示 -----
                            Container(
                                padding: const EdgeInsets.symmetric(
                                    horizontal: 20, vertical: 10),
                                decoration: BoxDecoration(
                                    color: Colors.green[100],
                                    borderRadius: BorderRadius.circular(10)),
                                child: Text(message.text).urlToLink(context)),
                            const SizedBox(
                              height: 20,
                            ),
                            // ----- URLが有効な場合のみカードを表示 -----
                            Visibility(
                              visible: message.showPreview,
                              child: Column(
                                children: [
                                  CardItem(
                                    url: message.url ?? '',
                                  ),
                                  const SizedBox(
                                    height: 10,
                                  ),
                                ],
                              ),
                            ),
                          ],
                        ),
                      );
                    }))
                : const SizedBox.shrink(),
          ),
          bottomSheet: Padding(
            padding:
                const EdgeInsets.symmetric(horizontal: 10.0, vertical: 10.0),
            child: Row(
              children: [
                // ----- メッセージ入力 -----
                Expanded(
                    child: TextFormField(
                  controller: messageController,
                  maxLines: 3,
                  decoration: const InputDecoration(
                    border: OutlineInputBorder(),
                    hintText: '入力してください。',
                  ),
                )),
                GestureDetector(
                    onTap: () async {
                      notifier.addMessage(
                        message: messageController.text,
                      );
                      messageController.clear();
                    },
                    child: const Icon(Icons.play_arrow))
              ],
            ),
          ),
        ),
      ),
    );
  }
}

メッセージにURLが含まれている場合、Statemessage.showPreviewtrueに設定され、結果としてリンクプレビューが表示されます。一方、URLが含まれていない場合、そのメッセージは単なる文字列として表示されます。これにより、URLの有無による表示の違いを確認することができます。

URL先のメタデータに「title(タイトル)」、「description(説明)」が存在しない、またはURLが間違っている場合、エラーウィジェットが表示されます。

リンクプレビューをタップすることでURL先に遷移することができます。

まとめ

any_link_previewパッケージを使用すると、ウェブページのURLからリンクプレビューを簡単に表示できます。この記事では詳細な実装は行っていませんが、このパッケージは自由にカスタマイズ可能です。あなたのアプリに最適な形に調整してみてください。

採用情報はこちら
目次