🔎

Flutterで簡単なキーワード検索を実装し、ついでに一致したキーワードをハイライトする

2020/12/10に公開

この記事は、過去にQiitaに投稿した記事に編集を加えたものです。

検索機能

事前に用意された配列から該当するアイテムを検索する機能を実装します。
今回は検索対象に含まれるスペースは考慮しません。

// 「Something 1 ~ Something 10」を検索対象としてセット
final List<String> searchTargets =
    List.generate(10, (index) => 'Something ${index + 1}');

List<String> searchResults = [];
void search(String query, {bool isCaseSensitive = false}) {
  // TextFieldが空になっていたら検索結果をリセットする
  if (query.isEmpty) {
    setState(() {
      searchResults.clear();
    });
    return;
  }

  final List<String> hitItems = searchTargets.where((element) {
    if (isCaseSensitive) {
      return element.contains(query);
    }
    return element.toLowerCase().contains(query.toLowerCase());
  }).toList();

  setState(() {
    searchResults = hitItems;
  });
}

ハイライト

検索結果に表示される文字列内の、キーワードと一致する部分をハイライトします。

まず、一致のパターンについて考えてみます。

  1. 一致なし
  2. 前方一致
  3. 部分一致
  4. 後方一致
  5. 完全一致

 

文字列は最少で1つ、最多で3つに分割されます。

(一致していない部分) | (一致している部分) | (一致していない部分)
の形で分割してやればよさそうです。

Qiitaの記事の本文では僕の冗長な方法を掲載していましたが、@sankentouさんがコメントでいい方法を教えてくだいました。
教えてもらったコードを本記事用に少し変更しています。🙇

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

class HighlightedText extends StatelessWidget {
  HighlightedText({
     this.wholeString,
     this.highlightedString,
    this.defaultStyle = const TextStyle(color: Colors.black),
    this.highlightStyle = const TextStyle(color: Colors.blue),
    this.isCaseSensitive = false,
  });

  final String wholeString;
  final String highlightedString;
  final TextStyle defaultStyle;
  final TextStyle highlightStyle;
  final bool isCaseSensitive;

  int get _highlightStart {
    if (isCaseSensitive) {
      return wholeString.indexOf(highlightedString);
    }
    return wholeString.toLowerCase().indexOf(highlightedString.toLowerCase());
  }

  int get _highlightEnd => _highlightStart + highlightedString.length;

  
  Widget build(BuildContext context) {
    // indexOf()は該当する要素が見つからない場合「-1」を返す。
    // 検索キーワードを含んでいないので、ハイライトされていない素のテキストを表示。
    if (_highlightStart == -1) {
      return Text(wholeString, style: defaultStyle);
    }
    return RichText(
      text: TextSpan(
        style: defaultStyle,
        children: [
          TextSpan(text: wholeString.substring(0, _highlightStart)),
          TextSpan(
            text: wholeString.substring(_highlightStart, _highlightEnd),
            style: highlightStyle,
          ),
          TextSpan(text: wholeString.substring(_highlightEnd))
        ],
      ),
    );
  }
}

コード全体

長いので折りたたみました
main.dart
import 'package:flutter/material.dart';

import './search_page.dart';

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

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Search Items',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const SearchPage(),
    );
  }
}
search_page.dart
import 'package:flutter/material.dart';

import './highlighted_text.dart';

class SearchPage extends StatefulWidget {
  const SearchPage({Key key}) : super(key: key);

  
  _SearchPageState createState() => _SearchPageState();
}

class _SearchPageState extends State<SearchPage> {
  TextEditingController controller;
  bool isCaseSensitive = false;

  final List<String> searchTargets =
      List.generate(10, (index) => 'Something ${index + 1}');

  List<String> searchResults = [];

  void search(String query, {bool isCaseSensitive = false}) {
    if (query.isEmpty) {
      setState(() {
        searchResults.clear();
      });
      return;
    }

    final List<String> hitItems = searchTargets.where((element) {
      if (isCaseSensitive) {
        return element.contains(query);
      }
      return element.toLowerCase().contains(query.toLowerCase());
    }).toList();

    setState(() {
      searchResults = hitItems;
    });
  }

  
  void initState() {
    super.initState();
    controller = TextEditingController();
  }

  
  void dispose() {
    super.dispose();
    controller.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Search Items'),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            SwitchListTile(
              title: const Text('Case Sensitive'),
              value: isCaseSensitive,
              onChanged: (bool newVal) {
                setState(() {
                  isCaseSensitive = newVal;
                });
                search(controller.text, isCaseSensitive: newVal);
              },
            ),
            TextField(
              controller: controller,
              decoration: InputDecoration(hintText: 'Enter keyword'),
              onChanged: (String val) {
                search(val, isCaseSensitive: isCaseSensitive);
              },
            ),
            ListView.builder(
              shrinkWrap: true,
              physics: const NeverScrollableScrollPhysics(),
              itemCount: searchResults.length,
              itemBuilder: (BuildContext context, int index) {
                return ListTile(
                  title: HighlightedText(
                    wholeString: searchResults[index],
                    highlightedString: controller.text,
                    isCaseSensitive: isCaseSensitive,
                  ),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}
highlighted_text.dart
import 'package:flutter/material.dart';

class HighlightedText extends StatelessWidget {
  HighlightedText({
     this.wholeString,
     this.highlightedString,
    this.defaultStyle = const TextStyle(color: Colors.black),
    this.highlightStyle = const TextStyle(color: Colors.blue),
    this.isCaseSensitive = false,
  });

  final String wholeString;
  final String highlightedString;
  final TextStyle defaultStyle;
  final TextStyle highlightStyle;
  final bool isCaseSensitive;

  int get _highlightStart {
    if (isCaseSensitive) {
      return wholeString.indexOf(highlightedString);
    }
    return wholeString.toLowerCase().indexOf(highlightedString.toLowerCase());
  }

  int get _highlightEnd => _highlightStart + highlightedString.length;

  
  Widget build(BuildContext context) {
    if (_highlightStart == -1) {
      return Text(wholeString, style: defaultStyle);
    }
    return RichText(
      text: TextSpan(
        style: defaultStyle,
        children: [
          TextSpan(text: wholeString.substring(0, _highlightStart)),
          TextSpan(
            text: wholeString.substring(_highlightStart, _highlightEnd),
            style: highlightStyle,
          ),
          TextSpan(text: wholeString.substring(_highlightEnd))
        ],
      ),
    );
  }
}

検索対象は「Something 1 ~ Somthing 10」です。

Discussion