Last active
November 9, 2025 01:05
-
-
Save noga-dev/b112c85ec9d98262ba2a4d4f5b1c5f8a to your computer and use it in GitHub Desktop.
Flutter toc section with indicator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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