From 4e87d812bf68fc25696b3dff1e0b735a3fc3e6c3 Mon Sep 17 00:00:00 2001 From: vitormpp Date: Wed, 21 Aug 2024 22:11:06 +0100 Subject: [PATCH 1/4] feat: initial implementation of timeline --- packages/uni_ui/lib/timeline/timeline.dart | 105 +++++++++++++++++++++ packages/uni_ui/pubspec.yaml | 1 + 2 files changed, 106 insertions(+) create mode 100644 packages/uni_ui/lib/timeline/timeline.dart diff --git a/packages/uni_ui/lib/timeline/timeline.dart b/packages/uni_ui/lib/timeline/timeline.dart new file mode 100644 index 000000000..87fd41d88 --- /dev/null +++ b/packages/uni_ui/lib/timeline/timeline.dart @@ -0,0 +1,105 @@ +import 'package:figma_squircle/figma_squircle.dart'; +import 'package:flutter/material.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +class Timeline extends StatefulWidget { + const Timeline({ + required this.tabs, + required this.content, + super.key, + }); + + final List tabs; + final List content; + + @override + State createState() => _TimelineState(); +} + +class _TimelineState extends State { + int _currentIndex = 0; + final ItemScrollController _itemScrollController = ItemScrollController(); + final ItemPositionsListener _itemPositionsListener = + ItemPositionsListener.create(); + + @override + void initState() { + super.initState(); + + _itemPositionsListener.itemPositions.addListener(() { + final positions = _itemPositionsListener.itemPositions.value; + if (positions.isNotEmpty) { + final firstVisibleIndex = positions + .where((ItemPosition position) => position.itemLeadingEdge >= 0) + .reduce((ItemPosition current, ItemPosition next) => + current.itemLeadingEdge < next.itemLeadingEdge ? current : next) + .index; + + setState(() { + _currentIndex = firstVisibleIndex; + }); + } + }); + } + + void _onTabTapped(int index) { + setState(() { + _currentIndex = index; + }); + _itemScrollController.scrollTo( + index: index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: widget.tabs.asMap().entries.map((entry) { + int index = entry.key; + Widget tab = entry.value; + return GestureDetector( + onTap: () => _onTabTapped(index), + child: Padding( + padding: const EdgeInsets.all(7.0), + child: ClipSmoothRect( + radius: SmoothBorderRadius( + cornerRadius: 20, + cornerSmoothing: 1, + ), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 10.0, horizontal: 15.0), + color: _currentIndex == index + ? Theme.of(context) + .colorScheme + .tertiary + .withOpacity(0.25) + : Colors.transparent, + child: tab, + ), + ), + ), + ); + }).toList(), + ), + ), + Expanded( + child: ScrollablePositionedList.builder( + itemCount: widget.content.length, + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + itemBuilder: (context, index) { + return widget.content[index]; + }, + ), + ), + ], + ); + } +} diff --git a/packages/uni_ui/pubspec.yaml b/packages/uni_ui/pubspec.yaml index 9f518255d..814c27389 100644 --- a/packages/uni_ui/pubspec.yaml +++ b/packages/uni_ui/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: figma_squircle: ^0.5.3 flutter: sdk: flutter + scrollable_positioned_list: ^0.3.5 dev_dependencies: custom_lint: ^0.6.4 From 102de06267014cf32e50dc0352dffcdc86f89194 Mon Sep 17 00:00:00 2001 From: vitormpp Date: Wed, 28 Aug 2024 23:19:10 +0100 Subject: [PATCH 2/4] feat: scrollable app bar --- packages/uni_ui/lib/main.dart | 329 +++++++++++++++++++++ packages/uni_ui/lib/timeline/timeline.dart | 38 ++- 2 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 packages/uni_ui/lib/main.dart diff --git a/packages/uni_ui/lib/main.dart b/packages/uni_ui/lib/main.dart new file mode 100644 index 000000000..fc1d906a2 --- /dev/null +++ b/packages/uni_ui/lib/main.dart @@ -0,0 +1,329 @@ +import 'package:flutter/material.dart'; +import 'package:uni_ui/timeline/timeline.dart'; + +void main() { + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData.light(), + home: Scaffold( + appBar: AppBar( + title: Text('Timeline Example'), + ), + body: Timeline( + content: [ + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 1', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.green[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 2', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut ' + 'odit aut fugit, sed quia consequuntur magni dolores eos qui ' + 'ratione voluptatem sequi nesciunt.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, ' + 'consectetur, adipisci velit, sed quia non numquam eius modi ' + 'tempora incidunt ut labore et dolore magnam aliquam quaerat ' + 'voluptatem.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Ut enim ad minima veniam, quis nostrum exercitationem ullam ' + 'corporis suscipit laboriosam, nisi ut aliquid ex ea commodi ' + 'consequatur?', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.blue[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 3', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Quis autem vel eum iure reprehenderit qui in ea voluptate ' + 'velit esse quam nihil molestiae consequatur, vel illum qui ' + 'dolorem eum fugiat quo voluptas nulla pariatur?', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'But I must explain to you how all this mistaken idea of ' + 'denouncing pleasure and praising pain was born and I will ' + 'give you a complete account of the system, and expound the ' + 'actual teachings of the great explorer of the truth.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Nor again is there anyone who loves or pursues or desires to ' + 'obtain pain of itself, because it is pain, but because ' + 'occasionally circumstances occur in which toil and pain can ' + 'procure him some great pleasure.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 4', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 5', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 6', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + Container( + color: Colors.red[100], + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Content for Tab 7', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 8), + Text( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + 'Suspendisse eget tincidunt sapien. Phasellus sed ligula id ' + 'turpis vulputate efficitur. Donec ut arcu vel leo blandit ' + 'dictum. Cras ut massa nisi. Nulla facilisi. Quisque porta ' + 'lobortis diam, at interdum orci.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem ' + 'accusantium doloremque laudantium, totam rem aperiam, eaque ' + 'ipsa quae ab illo inventore veritatis et quasi architecto ' + 'beatae vitae dicta sunt explicabo.', + style: TextStyle(fontSize: 16), + ), + SizedBox(height: 16), + Text( + 'At vero eos et accusamus et iusto odio dignissimos ducimus ' + 'qui blanditiis praesentium voluptatum deleniti atque corrupti ' + 'quos dolores et quas molestias excepturi sint occaecati ' + 'cupiditate non provident.', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + ], + tabs: [ + Column( + children: [ + Text('Mon'), + Text('1'), + ], + ), + Column( + children: [ + Text('Tue'), + Text('2'), + ], + ), + Column( + children: [ + Text('Wed'), + Text('3'), + ], + ), + Column( + children: [ + Text('Thu'), + Text('4'), + ], + ), + Column( + children: [ + Text('Fri'), + Text('5'), + ], + ), + Column( + children: [ + Text('Sat'), + Text('6'), + ], + ), + Column( + children: [ + Text('Sun'), + Text('7'), + ], + ), + ], + ), + ), + ); + } +} diff --git a/packages/uni_ui/lib/timeline/timeline.dart b/packages/uni_ui/lib/timeline/timeline.dart index 87fd41d88..82c15cd81 100644 --- a/packages/uni_ui/lib/timeline/timeline.dart +++ b/packages/uni_ui/lib/timeline/timeline.dart @@ -21,11 +21,15 @@ class _TimelineState extends State { final ItemScrollController _itemScrollController = ItemScrollController(); final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create(); + final ScrollController _tabScrollController = ScrollController(); + final List _tabKeys = []; @override void initState() { super.initState(); + _tabKeys.addAll(List.generate(widget.tabs.length, (index) => GlobalKey())); + _itemPositionsListener.itemPositions.addListener(() { final positions = _itemPositionsListener.itemPositions.value; if (positions.isNotEmpty) { @@ -35,22 +39,40 @@ class _TimelineState extends State { current.itemLeadingEdge < next.itemLeadingEdge ? current : next) .index; - setState(() { - _currentIndex = firstVisibleIndex; - }); + if (_currentIndex != firstVisibleIndex) { + setState(() { + _currentIndex = firstVisibleIndex; + }); + + _scrollToCenterTab(firstVisibleIndex); + } } }); } void _onTabTapped(int index) { - setState(() { - _currentIndex = index; - }); _itemScrollController.scrollTo( index: index, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); + _scrollToCenterTab(index); + } + + void _scrollToCenterTab(int index) { + final screenWidth = MediaQuery.of(context).size.width; + final RenderBox tabBox = + _tabKeys[index].currentContext!.findRenderObject() as RenderBox; + final tabPosition = tabBox.localToGlobal(Offset.zero); + + final tabWidth = tabBox.size.width; + final offset = tabPosition.dx + (tabWidth / 2) - (screenWidth / 2); + + _tabScrollController.animateTo( + _tabScrollController.offset + offset, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); } @override @@ -59,6 +81,7 @@ class _TimelineState extends State { children: [ SingleChildScrollView( scrollDirection: Axis.horizontal, + controller: _tabScrollController, child: Row( children: widget.tabs.asMap().entries.map((entry) { int index = entry.key; @@ -69,10 +92,11 @@ class _TimelineState extends State { padding: const EdgeInsets.all(7.0), child: ClipSmoothRect( radius: SmoothBorderRadius( - cornerRadius: 20, + cornerRadius: 10, cornerSmoothing: 1, ), child: Container( + key: _tabKeys[index], padding: const EdgeInsets.symmetric( vertical: 10.0, horizontal: 15.0), color: _currentIndex == index From 18420c2fc6cf8771f1aef6a8502740e7a71b4edb Mon Sep 17 00:00:00 2001 From: vitormpp Date: Thu, 29 Aug 2024 16:07:54 +0100 Subject: [PATCH 3/4] fix: add range constraint to tab bar scrolling --- packages/uni_ui/lib/timeline/timeline.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/uni_ui/lib/timeline/timeline.dart b/packages/uni_ui/lib/timeline/timeline.dart index 82c15cd81..e4c2e2a20 100644 --- a/packages/uni_ui/lib/timeline/timeline.dart +++ b/packages/uni_ui/lib/timeline/timeline.dart @@ -63,13 +63,19 @@ class _TimelineState extends State { final screenWidth = MediaQuery.of(context).size.width; final RenderBox tabBox = _tabKeys[index].currentContext!.findRenderObject() as RenderBox; - final tabPosition = tabBox.localToGlobal(Offset.zero); final tabWidth = tabBox.size.width; - final offset = tabPosition.dx + (tabWidth / 2) - (screenWidth / 2); + final offset = (_tabScrollController.offset + + tabBox.localToGlobal(Offset.zero).dx + + (tabWidth / 2) - + (screenWidth / 2)) + .clamp( + 0.0, + _tabScrollController.position.maxScrollExtent, + ); _tabScrollController.animateTo( - _tabScrollController.offset + offset, + offset, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); From 7b87a4e1fb733c58eaf715126663cfc65d9b9ca5 Mon Sep 17 00:00:00 2001 From: Vitor Pires Date: Wed, 2 Oct 2024 17:08:51 +0100 Subject: [PATCH 4/4] update lock --- packages/uni_ui/pubspec.lock | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/uni_ui/pubspec.lock b/packages/uni_ui/pubspec.lock index 1f09ca040..20675b329 100644 --- a/packages/uni_ui/pubspec.lock +++ b/packages/uni_ui/pubspec.lock @@ -403,6 +403,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.dev" + source: hosted + version: "0.3.8" shelf: dependency: transitive description: