[Flutter] キーワードと一致する文字列をハイライトする方法

# はじめに
検索機能を実装した際、キーワードとの一致箇所をハイライトする機能がほしくなったので方法を探してみたのですが見つからなかったので自力で実装しました。

![Screenshot_1572937804.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470994/075e0fbc-d44c-4871-9d68-2fe569eecb03.png)

デモ用にアプリを作成してコードを掲載していますのでコピペして試してみてください。


# おことわり
今回紹介する方法ではスペースを含む文字列は正常にハイライトされません。



# 考えられる一致のパターン
- 一致する箇所なし
![pattern0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470994/0b372041-45b4-2294-9c36-6ab0782b4cfd.png)

- 文字列の先頭で一致
![pattern1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470994/78daab0a-e29e-452c-1f94-3d3a93ee073b.png)

- 文字列の途中で一致
![pattern2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470994/139dbe50-3ae2-837d-7f1a-8c3885badf3d.png)

- 文字列の末尾で一致
![pattern3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470994/410ef71c-3b33-4961-6f17-6a9f2bed5ba6.png)

- 文字列全体が一致
![pattern4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470994/54243b9a-d9c8-1947-d7c7-da0fb68e6931.png)


つまり、文字列は最少で1つ、最多で3つに分割されます。
Textウィジェットを3つ持つ配列を返すような関数にしてやればよさそうです。



# コード
### ハイライトの処理
```dart
List<Widget> getHighlightedText(String originalString, String inputString) {
  // 大文字/小文字の区別をなくすため、すべて小文字に変換
  final String lowerOriginalString = originalString.toLowerCase();
  final String lowerInputString = inputString.toLowerCase();
  // もとの文字列における入力された文字列の最初の文字のインデックス
  final int firstOfInputString = lowerOriginalString.indexOf(lowerInputString);
  // もとの文字列における入力された文字列の最後の文字のインデックス
  final int lastOfInputString =
        lowerOriginalString.indexOf(lowerInputString) +
            (lowerInputString.length - 1);
  final double _fontSize = 24.0;
  final Color _highlightColor = Colors.blue;


  // inputStringと一致する箇所がない場合のエラー(Value not in range: -1)回避
  if (firstOfInputString == -1) {
    return [Container()];
  }

  final List<Widget> highlightedText = [
    Text(
      originalString.substring(0, firstOfInputString),
      style: TextStyle(
        fontSize: _fontSize,
      ),
    ),
    Text(
      originalString.substring(firstOfInputString, lastOfInputString + 1),
      style: TextStyle(
        color: _highlightColor,
        fontSize: _fontSize,
      ),
    ),
    Text(
      originalString.substring(lastOfInputString + 1),
      style: TextStyle(
        fontSize: _fontSize,
      ),
    ),
  ];
  return highlightedText;
}
```


### アプリ全体
```main.dart
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Highlight Text Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Highlight Text Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String originalText = '';
  String inputText = '';
  String highlightedText = '';
  final TextEditingController _originalTextController = TextEditingController();
  final TextEditingController _highlightedPartController =
      TextEditingController();

  void refreshScreen() {
    setState(() {
      originalText = '';
      inputText = '';
      highlightedText = '';
      _originalTextController.clear();
      _highlightedPartController.clear();
    });
  }

  List<Widget> getHighlightedText(String originalString, String inputString) {
    List<Widget> highlightedText;
    // 大文字/小文字の区別をなくすため、すべて小文字に変換
    final String lowerOriginalString = originalString.toLowerCase();
    final String lowerInputString = inputString.toLowerCase();
    // もとの文字列の最後の文字のインデックス
    final int lastOfOriginalString = originalString.length - 1;
    // もとの文字列における入力された文字列の最初の文字のインデックス
    final int firstOfInputString = lowerOriginalString.indexOf(lowerInputString);
    // もとの文字列における入力された文字列の最後の文字のインデックス
    int lastOfInputString;
    final double _fontSize = 24.0;
    final Color _highlightColor = Colors.blue;
    
    
    // inputStringと一致する箇所がない場合のエラー(Value not in range: -1)回避
    if (firstOfInputString == -1) {
      return [Container()];
    }
    
    highlightedText = [
      Text(
        originalString.substring(0, firstOfInputString),
        style: TextStyle(
          fontSize: _fontSize,
        ),
      ),
      Text(
        originalString.substring(firstOfInputString, lastOfInputString + 1),
        style: TextStyle(
          color: _highlightColor,
          fontSize: _fontSize,
        ),
      ),
      Text(
        originalString.substring(lastOfInputString + 1),
        style: TextStyle(
          fontSize: _fontSize,
        ),
      ),
    ];
    return highlightedText;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Highlight Text'),
      ),
      body: Padding(
        padding: EdgeInsets.all(15.0),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Row(
              children: <Widget>[
                const Text(
                  'Original Text: ',
                  style: TextStyle(fontSize: 16),
                ),
                Flexible(
                  child: originalText == ''
                      ? TextField(
                          controller: _originalTextController,
                          onEditingComplete: () {
                            setState(() {
                              originalText = _originalTextController.text;
                            });
                          },
                        )
                      : Text(
                          originalText,
                          style: const TextStyle(fontSize: 24),
                        ),
                ),
              ],
            ),
            const SizedBox(height: 30.0),
            Row(
              children: <Widget>[
                const Text(
                  'Highlighted Text: ',
                  style: TextStyle(fontSize: 16),
                ),
                inputText == ''
                    ? Text(
                        originalText,
                        style: const TextStyle(fontSize: 24.0),
                      )
                    : Row(
                        children: getHighlightedText(originalText, inputText),
                      ),
              ],
            ),
            const SizedBox(height: 60.0),
            TextField(
              controller: _highlightedPartController,
              enabled: originalText == '' ? false : true,
              decoration: const InputDecoration(hintText: 'Keyword'),
              onChanged: (value) {
                setState(() {
                  inputText = value;
                });
              },
            ),
            const SizedBox(height: 60.0),
            RaisedButton(
              onPressed: refreshScreen,
              child: const Text('Refresh'),
            ),
          ],
        ),
      ),
    );
  }
}
```


![flutter_highlight_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470994/25a7a517-b732-0bf0-aecd-875479cd9d54.gif)


日本語でもハイライトされました。

![Screenshot_1571062039.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/470994/f20ad6bd-3dc9-97b5-52ad-1307c02e1a93.png)



# さいごに
はじめはハイライトのパターンによって条件分岐して必要な数だけTextウィジェットを返すようにしようとしましたが、思いの外考慮する条件が多くてコードが複雑になってしまったためこのようにパターンに関係なく文字列を3分割する形をとりました。

また、よく見るとおわかりいただけると思いますが、ハイライトされた文字列とされていない文字列の間が若干広くなってしまいます。ハイライトされたものとされていないものを見比べなければわからないぐらいの差ではありますが、気になる方はコードを改良して使ってみてください。

フォントサイズやカラーなんかは関数の引数に渡して任意に指定できるようにしてもいいかもしれませんね。