Skip to content

Instantly share code, notes, and snippets.

@noga-dev
Last active November 9, 2025 01:05
Show Gist options
  • Select an option

  • Save noga-dev/b112c85ec9d98262ba2a4d4f5b1c5f8a to your computer and use it in GitHub Desktop.

Select an option

Save noga-dev/b112c85ec9d98262ba2a4d4f5b1c5f8a to your computer and use it in GitHub Desktop.
Flutter toc section with indicator
import 'package:flutter/material.dart';
// --- DATA STRUCTURE FOR CONTENT SECTIONS ---
/// Defines a section of the document.
class ContentSection {
final String title;
final String content;
final GlobalKey key = GlobalKey(); // Unique key to find its position
ContentSection({required this.title, required this.content});
}
// --- MAIN APPLICATION SETUP ---
void main() {
// Use `debugShowCheckedModeBanner: false` to keep the UI clean
runApp(MaterialApp(
theme: ThemeData.dark(),
title: 'Scrollable Content Demo',
debugShowCheckedModeBanner: false,
home: ScrollableContentWithTOC(),
));
}
// --- CORE WIDGET: SCROLLABLE CONTENT WITH TOC ---
class ScrollableContentWithTOC extends StatefulWidget {
const ScrollableContentWithTOC({super.key});
@override
State<ScrollableContentWithTOC> createState() =>
_ScrollableContentWithTOCState();
}
class _ScrollableContentWithTOCState extends State<ScrollableContentWithTOC> {
// 1. Controller for the main content scroll view
final ScrollController _scrollController = ScrollController();
// 2. Data model for the document
late final List<ContentSection> _sections;
// 3. State variable to track which section is currently visible (Active TOC item)
String _activeSectionTitle = '';
// 4. State variable for the progress *within* the active section (0.0 to 1.0)
double _sectionProgress = 0.0;
// 5. Map to store the pixel offset of each section header
final Map<String, double> _sectionOffsets = {};
// 6. GlobalKey for the SingleChildScrollView to get its RenderBox directly
final GlobalKey _scrollAreaKey = GlobalKey();
@override
void initState() {
super.initState();
// Initialize the dummy content
_sections = _createDummySections();
if (_sections.isNotEmpty) {
_activeSectionTitle = _sections.first.title;
}
// Attach listener to the scroll controller
_scrollController.addListener(_scrollListener);
// After the first frame is built, calculate the position of each section.
WidgetsBinding.instance.addPostFrameCallback((_) {
_calculateSectionOffsets();
// Call listener once to set initial state
_scrollListener();
});
}
@override
void dispose() {
_scrollController.removeListener(_scrollListener);
_scrollController.dispose();
super.dispose();
}
/// Calculates and stores the starting pixel offset for each section header.
void _calculateSectionOffsets() {
// Ensure the scroll controller is attached and has dimensions
if (!_scrollController.hasClients) return;
// Get the RenderBox of the SingleChildScrollView itself via its key
final RenderBox? scrollRenderBox =
_scrollAreaKey.currentContext?.findRenderObject() as RenderBox?;
if (scrollRenderBox == null) return;
_sectionOffsets.clear();
for (var section in _sections) {
final RenderBox? renderBox =
section.key.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null) {
// Find the position of the section header relative to the scrollable content area
final localPosition =
scrollRenderBox.globalToLocal(renderBox.localToGlobal(Offset.zero));
_sectionOffsets[section.title] = localPosition.dy;
}
}
}
/// Listens to scroll events to update active section and progress.
void _scrollListener() {
// A. Check for max scroll extent to avoid division by zero
if (!_scrollController.position.hasContentDimensions ||
_sectionOffsets.isEmpty) return;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentOffset = _scrollController.offset;
String newActiveSection = _activeSectionTitle;
double newSectionProgress = 0.0;
// B. Determine Active Section and Section-Specific Progress
// Iterate through sections to find which one is currently in view
for (int i = 0; i < _sections.length; i++) {
final section = _sections[i];
final sectionStartOffset = _sectionOffsets[section.title] ?? 0.0;
// Determine the end offset for this section
double sectionEndOffset;
if (i == _sections.length - 1) {
// Last section: its "end" is the max scroll position
sectionEndOffset = maxScroll;
} else {
// Any other section: its "end" is the start of the next section
sectionEndOffset =
_sectionOffsets[_sections[i + 1].title] ?? double.infinity;
}
// Check if the current scroll offset is within this section's bounds
if (currentOffset >= sectionStartOffset &&
currentOffset < sectionEndOffset) {
newActiveSection = section.title;
final sectionLength = sectionEndOffset - sectionStartOffset;
// Calculate progress *within* this section
newSectionProgress = sectionLength > 0
? (currentOffset - sectionStartOffset) / sectionLength
: 0.0;
break; // Found the active section, no need to check others
}
// Handle the very last section explicitly if we've scrolled to the end
if (i == _sections.length - 1 && currentOffset >= sectionEndOffset) {
newActiveSection = section.title;
newSectionProgress = 1.0;
break;
}
}
// C. Update state if anything changed
if (newActiveSection != _activeSectionTitle ||
(newSectionProgress - _sectionProgress).abs() > 0.01) {
setState(() {
_activeSectionTitle = newActiveSection;
_sectionProgress = newSectionProgress.clamp(0.0, 1.0);
});
}
}
/// Jumps the main content scroll view to the selected section.
void _jumpToSection(GlobalKey key) {
final context = key.currentContext;
if (context != null) {
Scrollable.ensureVisible(
context,
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
alignment: 0.05, // Small offset from the very top
);
}
}
@override
Widget build(BuildContext context) {
final activeIndex =
_sections.indexWhere((s) => s.title == _activeSectionTitle);
return Scaffold(
appBar: AppBar(
title: const Text('Knowledge Base Reader'),
backgroundColor: Colors.indigo,
// The ProgressIndicator has been removed from here
),
body: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left Panel: Table of Contents (TOC)
Container(
width: 280, // Fixed width for desktop/tablet style TOC
decoration: BoxDecoration(
border: Border(right: BorderSide(color: Colors.grey.shade800)),
),
padding: const EdgeInsets.only(top: 20, right: 10, left: 10),
child: ListView.builder(
itemCount: _sections.length,
itemBuilder: (context, index) {
final section = _sections[index];
final bool isActive = section.title == _activeSectionTitle;
// Determine the progress for this specific item
double itemProgress = 0.0;
if (index < activeIndex) {
itemProgress = 1.0; // Completed sections are 100%
} else if (isActive) {
itemProgress = _sectionProgress; // Active section shows current progress
}
// Future sections remain 0.0
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
// Stack allows us to layer the progress bar *behind* the text
child: Stack(
children: [
// The progress indicator "fill"
Positioned.fill(
child: FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: itemProgress,
child: Container(
decoration: BoxDecoration(
color: Colors.indigo.shade700,
borderRadius: BorderRadius.circular(8),
),
),
),
),
// The visible ListTile on top
ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16.0),
title: Text(
section.title,
style: TextStyle(
fontSize: 14,
fontWeight:
isActive ? FontWeight.bold : FontWeight.normal,
// Ensure text is always readable
color: (isActive || itemProgress > 0)
? Colors.white
: Colors.grey.shade400,
),
),
// Show checkmark for completed, arrow for active
leading: (isActive || itemProgress == 1.0)
? Icon(
itemProgress == 1.0
? Icons.check_circle_outline
: Icons.arrow_right_rounded,
color: Colors.white,
size: 20,
)
// Use a placeholder to keep text aligned
: const SizedBox(width: 24, height: 20),
onTap: () => _jumpToSection(section.key),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
],
),
);
},
),
),
// Right Panel: Main Content Area
Expanded(
child: SingleChildScrollView(
key: _scrollAreaKey, // Assign the key here
controller: _scrollController,
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: _sections.map((section) {
return Padding(
padding: const EdgeInsets.only(bottom: 40.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Title (This is what the GlobalKey tracks)
Text(
section.title,
key: section.key, // Assign the key here
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
color: Colors.indigo.shade100,
),
),
const Divider(
height: 20, thickness: 1, color: Colors.grey),
// Section Content
Text(
section.content,
style: const TextStyle(
fontSize: 16,
height: 1.6,
color: Colors.white70,
),
),
],
),
);
}).toList(),
),
),
),
],
),
);
}
// --- HELPER FUNCTION: DUMMY CONTENT GENERATION ---
List<ContentSection> _createDummySections() {
const longText =
'As a freelance full-stack developer, I understand the desire for passive SaaS income. The journey involves deep dives into architecture (like this component!), and persistent iteration. Focus on a clear value proposition, minimal viable product (MVP), and efficient state management. This component uses GlobalKey, which is excellent for finding the exact on-screen position of a widget, a technique critical for highly interactive UI elements in Flutter. Remember, the true value of a SaaS often lies in its niche expertise and reliability. The text here is just to pad the content so that the scrollbar has enough content to scroll. In a real application, this would be dynamic content loaded from a database or API. The length of this text is important to test the scroll tracking logic effectively, ensuring the progress bar and the active section detection work correctly across various screen sizes and scroll speeds. The scrolling mechanism must be smooth, and the active section update should be nearly instantaneous to give the user a good experience.';
final sections = <ContentSection>[];
for (int i = 1; i <= 10; i++) {
sections.add(
ContentSection(
title: 'Section $i: The Importance of Niche Focus',
content: 'This is the content for Section $i. $longText' *
(i < 3 ? 3 : 5), // Vary content length
),
);
}
return sections;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment