[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(feed): improve image carousel behavior and scrolling

+150 -5
+29 -5
lib/src/features/feed/ui/widgets/images/image_carousel.dart
··· 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:spark/src/core/ui/foundation/colors.dart'; 6 + import 'package:spark/src/features/feed/ui/widgets/images/moderate_page_scroll_physics.dart'; 6 7 7 8 class ImageCarousel extends ConsumerStatefulWidget { 8 9 const ImageCarousel({ ··· 29 30 @override 30 31 void initState() { 31 32 super.initState(); 32 - _pageController = PageController(); 33 + _pageController = PageController(initialPage: 0); 34 + currentIndex = 0; 33 35 // Create image providers for all images upfront 34 36 _imageProviders = widget.imageUrls 35 37 .map(CachedNetworkImageProvider.new) ··· 46 48 _preloadAllImages(); 47 49 _buildCachedPages(); 48 50 } 51 + // Ensure page controller is at page 0 after first frame 52 + WidgetsBinding.instance.addPostFrameCallback((_) { 53 + if (mounted && _pageController.hasClients) { 54 + final currentPage = _pageController.page?.round() ?? 0; 55 + if (currentPage != 0) { 56 + _pageController.jumpToPage(0); 57 + setState(() { 58 + currentIndex = 0; 59 + }); 60 + } 61 + } 62 + }); 49 63 } 64 + 50 65 51 66 @override 52 67 void dispose() { ··· 152 167 controller: _pageController, 153 168 itemCount: _cachedPages.length, 154 169 allowImplicitScrolling: true, 155 - itemBuilder: (context, index) => _cachedPages[index], 170 + physics: const ModeratePageScrollPhysics(), 171 + itemBuilder: (context, index) { 172 + // Ensure we only build pages that exist 173 + if (index >= 0 && index < _cachedPages.length) { 174 + return _cachedPages[index]; 175 + } 176 + return const SizedBox.shrink(); 177 + }, 156 178 onPageChanged: (index) { 157 - setState(() { 158 - currentIndex = index; 159 - }); 179 + if (index >= 0 && index < widget.imageUrls.length) { 180 + setState(() { 181 + currentIndex = index; 182 + }); 183 + } 160 184 }, 161 185 ), 162 186 Positioned(
+121
lib/src/features/feed/ui/widgets/images/moderate_page_scroll_physics.dart
··· 1 + import 'package:flutter/widgets.dart'; 2 + 3 + /// Moderately snappy scroll physics for photo carousels. 4 + /// Snappier than default but less snappy than SnappyPageScrollPhysics. 5 + class ModeratePageScrollPhysics extends PageScrollPhysics { 6 + const ModeratePageScrollPhysics({super.parent}); 7 + 8 + @override 9 + ModeratePageScrollPhysics applyTo(ScrollPhysics? ancestor) { 10 + return ModeratePageScrollPhysics(parent: buildParent(ancestor)); 11 + } 12 + 13 + @override 14 + SpringDescription get spring => SpringDescription.withDampingRatio( 15 + mass: 0.7, // Between default (1.0) and feed snappy (0.5) 16 + stiffness: 300, // Between default (100) and feed snappy (500) 17 + ratio: 1.1, // Slightly over-damped for smooth easing 18 + ); 19 + 20 + @override 21 + bool get allowImplicitScrolling => true; 22 + 23 + @override 24 + double get minFlingVelocity => 200; // Between default (50) and feed snappy (400) 25 + 26 + @override 27 + double get minFlingDistance => 25; // Between default (0) and feed snappy (20) 28 + 29 + @override 30 + Tolerance get tolerance => const Tolerance( 31 + velocity: 0.7, // Between default and feed snappy (0.5) 32 + distance: 0.7, // Less tight snapping than feed 33 + ); 34 + 35 + @override 36 + Simulation? createBallisticSimulation( 37 + ScrollMetrics position, 38 + double velocity, 39 + ) { 40 + // Only snap if there's actual user interaction 41 + // Don't snap on initial load or when position is at boundaries with no velocity 42 + final hasVelocity = velocity.abs() >= tolerance.velocity; 43 + 44 + // Check if we're at a boundary - if so, only snap if there's significant velocity 45 + final isAtMinBoundary = (position.pixels - position.minScrollExtent).abs() < 1.0; 46 + final isAtMaxBoundary = (position.maxScrollExtent - position.pixels).abs() < 1.0; 47 + 48 + // If at boundaries with no velocity, don't snap 49 + if ((isAtMinBoundary || isAtMaxBoundary) && !hasVelocity) { 50 + return super.createBallisticSimulation(position, velocity); 51 + } 52 + 53 + // Check for significant drag (user has actually moved the page) 54 + final hasSignificantDrag = (position.pixels - position.minScrollExtent).abs() > minFlingDistance && 55 + !isAtMinBoundary && !isAtMaxBoundary; 56 + 57 + if (hasVelocity || hasSignificantDrag) { 58 + final targetPage = _getTargetPage(position, velocity); 59 + final target = targetPage * position.viewportDimension; 60 + 61 + // Only create simulation if we're actually moving to a different page 62 + // and we're not already very close to the target 63 + if ((target - position.pixels).abs() > 1.0) { 64 + return ScrollSpringSimulation( 65 + spring, 66 + position.pixels, 67 + target, 68 + velocity, 69 + tolerance: tolerance, 70 + ); 71 + } 72 + } 73 + 74 + return super.createBallisticSimulation(position, velocity); 75 + } 76 + 77 + int _getTargetPage(ScrollMetrics position, double velocity) { 78 + final page = position.pixels / position.viewportDimension; 79 + final currentPage = page.floor(); 80 + final maxPage = (position.maxScrollExtent / position.viewportDimension) 81 + .floor(); 82 + 83 + // If there's sufficient velocity in a direction, commit to that direction 84 + if (velocity.abs() > minFlingVelocity) { 85 + final targetPage = velocity < 0 ? page.floor() : page.ceil(); 86 + // Don't go past boundaries 87 + return targetPage.clamp(0, maxPage); 88 + } 89 + 90 + // If dragged past 50% threshold, snap to next page (less aggressive than feed's 30%/70%) 91 + final progress = page - page.floor(); 92 + 93 + // At first page (0), only snap forward if dragged significantly past 50% 94 + if (currentPage == 0) { 95 + if (progress > 0.6 && currentPage < maxPage) { 96 + return 1; 97 + } 98 + return 0; // Stay on first page unless dragged significantly 99 + } 100 + 101 + // At last page, only snap backward if dragged significantly 102 + if (currentPage >= maxPage) { 103 + if (progress < 0.4 && currentPage > 0) { 104 + return currentPage - 1; 105 + } 106 + return maxPage; // Stay on last page unless dragged significantly 107 + } 108 + 109 + // Middle pages: use 50% threshold 110 + if (progress > 0.5 && currentPage < maxPage) { 111 + // Snap forward if dragged past 50% 112 + return (currentPage + 1).clamp(0, maxPage); 113 + } else if (progress < 0.5 && currentPage > 0) { 114 + // Snap back if less than 50% 115 + return currentPage.clamp(0, maxPage); 116 + } 117 + 118 + // Default: stay on current page 119 + return currentPage; 120 + } 121 + }