Skip to content

Instantly share code, notes, and snippets.

@slightfoot
Created October 29, 2025 18:40
Show Gist options
  • Select an option

  • Save slightfoot/b9313fcf9e7ed966683d61e9411a7fb8 to your computer and use it in GitHub Desktop.

Select an option

Save slightfoot/b9313fcf9e7ed966683d61e9411a7fb8 to your computer and use it in GitHub Desktop.
Alphabeta Scrollbar Contacts Widget - by Simon Lightfoot :: #HumpdayQandA on 29th October 2025 :: https://www.youtube.com/watch?v=jQA3lNm_kX0
// MIT License
//
// Copyright (c) 2025 Simon Lightfoot
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:charcode/ascii.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart' show SystemChrome, SystemUiMode, SystemUiOverlayStyle;
final alphabet = <int>[
for (int charCode = $a; charCode <= $z; charCode++) //
charCode,
];
final data = <(Color, String, int)>[
for (final (alpha, charCode) in alphabet.indexed) //
for (int index = 0; index < 3; index++) //
(
Colors.accents[(alpha * 3) % Colors.accents.length],
'Item ${String.fromCharCode(charCode)} #$index',
charCode,
),
];
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
void initState() {
super.initState();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
// SystemChrome.setSystemUIOverlayStyle(
// SystemUiOverlayStyle.light.copyWith(
// statusBarColor: Colors.transparent,
// systemNavigationBarColor: Colors.transparent,
// ),
// );
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: const Home(),
);
}
}
class Home extends StatefulWidget {
const Home({super.key});
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
final controller = AlphabetScrollController();
@override
Widget build(BuildContext context) {
return Material(
child: AlphabetScrollHost(
controller: controller,
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: data.length,
itemBuilder: (BuildContext context, int index) {
return AlphabetScrollListItem(
charCode: data[index].$3,
child: ListTile(
tileColor: data[index].$1,
title: Text(data[index].$2),
),
);
},
),
),
);
}
}
// ----------------------------------------------------------------------------
class AlphabetScrollHost extends StatefulWidget {
const AlphabetScrollHost({
super.key,
required this.controller,
required this.child,
});
final AlphabetScrollController controller;
final Widget child;
@override
State<AlphabetScrollHost> createState() => _AlphabetScrollHostState();
}
class _AlphabetScrollHostState extends State<AlphabetScrollHost> {
final _layerKey = GlobalKey();
@override
void initState() {
super.initState();
widget.controller.addListener(_findActiveCode);
}
@override
void dispose() {
widget.controller.removeListener(_findActiveCode);
super.dispose();
}
void _findActiveCode() {
final renderObject =
_layerKey.currentContext!.findRenderObject()! as _RenderAlphabetScrollLayer;
final annotatedResult = renderObject.findAnnotations<int>();
if (annotatedResult.entries.isNotEmpty) {
final code = annotatedResult.entries.first.annotation;
if (widget.controller.highlightedCode.value != code) {
print('highlighted ${String.fromCharCode(code)}');
scheduleMicrotask(() {
widget.controller.highlightedCode.value = code;
});
}
}
}
@override
Widget build(BuildContext context) {
return PrimaryScrollController(
controller: widget.controller,
child: _AlphabetScrollLayer(
key: _layerKey,
child: Stack(
children: [
widget.child,
Positioned(
top: 0.0,
right: 0.0,
bottom: 0.0,
child: AlphabetScrollBar(controller: widget.controller),
),
],
),
),
);
}
}
class AlphabetScrollController extends ScrollController {
AlphabetScrollController({
super.initialScrollOffset,
super.keepScrollOffset,
super.debugLabel,
super.onAttach,
super.onDetach,
});
final highlightedCode = ValueNotifier<int?>(null);
}
class AlphabetScrollListItem extends StatelessWidget {
const AlphabetScrollListItem({
super.key,
required this.charCode,
required this.child,
});
final int charCode;
final Widget child;
@override
Widget build(BuildContext context) {
return AnnotatedRegion<int>(
value: charCode,
child: child,
);
}
}
class AlphabetScrollBar extends StatelessWidget {
const AlphabetScrollBar({
super.key,
required this.controller,
});
final AlphabetScrollController controller;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int?>(
valueListenable: controller.highlightedCode,
builder: (BuildContext context, int? highlightedCode, Widget? child) {
return Material(
type: MaterialType.transparency,
child: DefaultTextStyle.merge(
style: const TextStyle(
fontSize: 14.0,
fontWeight: FontWeight.bold,
color: Colors.black,
height: 1.0,
),
child: SizedBox(
width: 32.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final charCode in alphabet) //
Builder(
builder: (BuildContext context) {
final highlighted = charCode == highlightedCode;
return Expanded(
child: InkWell(
onTap: () {
// controller.scrollToCharCode(charCode);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: Center(
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 200),
tween: highlighted ? Tween(end: 32.0) : Tween(end: 12.0),
builder: (BuildContext context, double fontSize, Widget? child) {
return Text(
String.fromCharCode(charCode),
style: TextStyle(
fontSize: fontSize,
color: highlighted ? Colors.white : null,
),
);
},
),
),
),
),
);
},
),
],
),
),
),
);
},
);
}
}
class _AlphabetScrollLayer extends SingleChildRenderObjectWidget {
const _AlphabetScrollLayer({super.key, required super.child});
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderAlphabetScrollLayer();
}
}
class _RenderAlphabetScrollLayer extends RenderProxyBox {
_RenderAlphabetScrollLayer({RenderBox? child}) : super(child);
@override
bool get isRepaintBoundary => true;
AnnotationResult<T> findAnnotations<T extends Object>() {
final annotatedResult = AnnotationResult<T>();
if (layer case OffsetLayer layer) {
layer.findAnnotations<T>(
annotatedResult,
layer.offset,
onlyFirst: true,
);
}
return annotatedResult;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment