GardenMobile & interfaces

Highlighting search matches in a Flutter list

An older RichText example that separates filtering records from marking matching spans.

Filtering a list decides which records appear. Highlighting decides which parts of each visible record receive emphasis. The original example kept those operations separate.

Render the marked spans

// list
return ListView.separated(
                    physics: const AlwaysScrollableScrollPhysics(),
                    itemCount: friends.length,
                    itemBuilder: (BuildContext context, int index) {
                      final Friend item = friends[index];
                      return _buildItem(context, index, item, provider);
                    },
                    separatorBuilder: (_, int index) => const Divider(),
                  );



// Build friend view
  Widget _buildItem(BuildContext context, int index, Friend item, ContactProvider provider) {
    return ListTile(
      leading: item.avatarUrl == null
          ? CircleAvatar(
              child: Text(item.name?.substring(0, 1)?.toUpperCase() ?? ''))
          : CircleAvatar(
              backgroundColor: Colors.white,
              backgroundImage: NetworkImage(item.avatarUrl)),
      title: RichText(
        text: TextSpan(
          children: provider.highlightOccurrences(item.name, provider.searchValue),
          style: const TextStyle(color: Colors.black),
        ),
      ),
      subtitle: RichText(
        text: TextSpan(
          children: provider.highlightOccurrences(item.phone, provider.searchValue),
          style: const TextStyle(color: Colors.black),
        ),
      ),
    );
  }

Filter and collect matches

// For display friends
  List<Friend> _friendsDto = <Friend>[];

  // Save search value
  String _searchValue;

  /// On search input change listener to validate form
  void onSearchValueChange(final String search) {
    searchValue = search;
    // Filter on _friends, and copy to display data
    friendsDto = _friends
        .where((Friend f) =>
            (f.name?.toLowerCase()?.contains(search?.toLowerCase() ?? '') ??
                true) ||
            (f.phone?.toLowerCase()?.contains(search?.toLowerCase() ?? '') ??
                true))
        .toList();
  }

  // bold search text
  // Usage:
  // RichText(
  //   text: TextSpan(
  //     children: highlightOccurrences(source, query),
  //     style: const TextStyle(color: Colors.black),
  //   ),
  // ),
  List<TextSpan> highlightOccurrences(String source, String query) {
    if (query == null || query.isEmpty) {
      return <TextSpan>[TextSpan(text: source)];
    }

    final List<Match> matches = <Match>[];
    for (final String token in query.trim().toLowerCase().split(' ')) {
      matches.addAll(token.allMatches(source.toLowerCase()));
    }

    if (matches.isEmpty) {
      return <TextSpan>[TextSpan(text: source)];
    }
    matches.sort((Match a, Match b) => a.start.compareTo(b.start));

    int lastMatchEnd = 0;
    final List<TextSpan> children = <TextSpan>[];
    const Color matchColor = Color(0xFF602885);
    for (final Match match in matches) {
      if (match.end <= lastMatchEnd) {
        // already matched -> ignore
      } else if (match.start <= lastMatchEnd) {
        children.add(TextSpan(
          text: source.substring(lastMatchEnd, match.end),
          style:
              const TextStyle(fontWeight: FontWeight.bold, color: matchColor),
        ));
      } else {
        children.add(TextSpan(
          text: source.substring(lastMatchEnd, match.start),
        ));

        children.add(TextSpan(
          text: source.substring(match.start, match.end),
          style:
              const TextStyle(fontWeight: FontWeight.bold, color: matchColor),
        ));
      }

      if (lastMatchEnd < match.end) {
        lastMatchEnd = match.end;
      }
    }

    if (lastMatchEnd < source.length) {
      children.add(TextSpan(
        text: source.substring(lastMatchEnd, source.length),
      ));
    }

    return children;
  }

The helper sorts token matches and avoids emitting the same overlapping range twice. Its case-insensitive search assumes that lowercased match indexes can be applied to the original string. Empty names, null fields, repeated spaces, and language-specific casing need review before treating it as a general text-search implementation.

The application-specific friend/provider models are not included. No contact data was read. Source: Gist revision.

← Back to the garden