├── demo ├── demo.jpeg └── test_page.dart ├── images └── ic_select_mark.png ├── CHANGELOG.md ├── publish.sh ├── .metadata ├── pubspec.yaml ├── LICENSE ├── .gitignore ├── README.md ├── pubspec.lock └── lib └── cascade_picker.dart /demo/demo.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xionghaoo/cascade_picker/HEAD/demo/demo.jpeg -------------------------------------------------------------------------------- /images/ic_select_mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xionghaoo/cascade_picker/HEAD/images/ic_select_mark.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.0.8 2 | 3 | sdk upgrade 4 | 5 | ## 0.0.7 6 | 7 | update doc 8 | 9 | ## 0.0.6 10 | 11 | bug修复 12 | 13 | ## 0.0.5 14 | 15 | 添加空安全特性 16 | 17 | ## 0.0.4 18 | 19 | 更新说明 -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export https_proxy=127.0.0.1:7890 4 | export http_proxy=127.0.0.1:7890 5 | 6 | flutter packages pub publish --dry-run 7 | flutter packages pub publish --server=https://pub.dartlang.org 8 | #flutter pub publish -v -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 2ae34518b87dd891355ed6c6ea8cb68c4d52bb9d 8 | channel: stable 9 | 10 | project_type: package 11 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: cascade_picker 2 | description: 级联选择器 3 | version: 0.0.8 4 | homepage: https://github.com/xionghaoo/cascade_picker 5 | 6 | environment: 7 | sdk: '>=3.2.3 <4.0.0' 8 | 9 | dependencies: 10 | flutter: 11 | sdk: flutter 12 | 13 | dev_dependencies: 14 | flutter_test: 15 | sdk: flutter 16 | 17 | flutter: 18 | uses-material-design: true 19 | assets: 20 | - images/ 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 xionghao 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | .dart_tool/ 26 | .flutter-plugins 27 | .flutter-plugins-dependencies 28 | .packages 29 | .pub-cache/ 30 | .pub/ 31 | build/ 32 | 33 | # Android related 34 | **/android/**/gradle-wrapper.jar 35 | **/android/.gradle 36 | **/android/captures/ 37 | **/android/gradlew 38 | **/android/gradlew.bat 39 | **/android/local.properties 40 | **/android/**/GeneratedPluginRegistrant.java 41 | 42 | # iOS/XCode related 43 | **/ios/**/*.mode1v3 44 | **/ios/**/*.mode2v3 45 | **/ios/**/*.moved-aside 46 | **/ios/**/*.pbxuser 47 | **/ios/**/*.perspectivev3 48 | **/ios/**/*sync/ 49 | **/ios/**/.sconsign.dblite 50 | **/ios/**/.tags* 51 | **/ios/**/.vagrant/ 52 | **/ios/**/DerivedData/ 53 | **/ios/**/Icon? 54 | **/ios/**/Pods/ 55 | **/ios/**/.symlinks/ 56 | **/ios/**/profile 57 | **/ios/**/xcuserdata 58 | **/ios/.generated/ 59 | **/ios/Flutter/App.framework 60 | **/ios/Flutter/Flutter.framework 61 | **/ios/Flutter/Flutter.podspec 62 | **/ios/Flutter/Generated.xcconfig 63 | **/ios/Flutter/app.flx 64 | **/ios/Flutter/app.zip 65 | **/ios/Flutter/flutter_assets/ 66 | **/ios/Flutter/flutter_export_environment.sh 67 | **/ios/ServiceDefinitions.json 68 | **/ios/Runner/GeneratedPluginRegistrant.* 69 | 70 | # Exceptions to above rules. 71 | !**/ios/**/default.mode1v3 72 | !**/ios/**/default.mode2v3 73 | !**/ios/**/default.pbxuser 74 | !**/ios/**/default.perspectivev3 75 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cascade_picker 2 | 3 | 级联选择器 4 | 5 | ## Usage 6 | 7 | #### 1\. Depend 8 | 9 | Add this to you package's `pubspec.yaml` file: 10 | 11 | ```yaml 12 | dependencies: 13 | cascade_picker: ^0.0.8 14 | ``` 15 | 16 | or 17 | 18 | ```yaml 19 | cascade_picker: 20 | git: 21 | url: git://github.com/xionghaoo/cascade_picker.git 22 | ``` 23 | 24 | #### 2\. Install 25 | 26 | Run command: 27 | 28 | ```bash 29 | $ flutter packages get 30 | ``` 31 | 32 | #### 3\. Import 33 | 34 | Import in Dart code: 35 | 36 | ```dart 37 | import 'package:cascade_picker/cascade_picker.dart'; 38 | ``` 39 | 40 | #### 4\. Show 41 | 42 | 选择项前面的图标位置:[***images/ic_select_mark.png***][3] 43 | 44 | ```dart 45 | /// CascadePicker的page是ListView,没有约束的情况下它的高度是无限的, 46 | /// 因此需要约束高度。 47 | /// 48 | /// final _cascadeController = CascadeController(); 49 | /// 50 | /// initialPageData: 第一页的数据 51 | /// nextPageData: 下一页的数据,点击当前页的选择项后调用该方法加载下一页 52 | /// - pageCallback: 用于传递下一页的数据给CascadePicker 53 | /// - currentPage: 当前是第几页 54 | /// - selectIndex: 当前选中第几项 55 | /// controller: 控制器,用于获取已选择的数据 56 | /// maxPageNum: 最大页数 57 | /// selectedIcon(可选): 已选中选项前面的图标,flutter package不能放本地资源文件,因此需要从外部传入, 58 | /// 图标在images文件夹下面 59 | /// 60 | /// Expand( 61 | /// child: CascadePicker( 62 | /// initialPageData: ['a', 'b', 'c', 'd'], 63 | /// nextPageData: (pageCallback, currentPage, selectIndex) async { 64 | /// pageCallback(['one', 'two', 'three']) 65 | /// }, 66 | /// controller: _cascadeController, 67 | /// maxPageNum: 4, 68 | /// selectedIcon: Image.asset("images/ic_select_mark.png", width: 10, height: 10, color: Colors.redAccent,), 69 | /// ) 70 | /// 71 | /// InkBox( 72 | /// child: Container(...) 73 | /// onTap: () { 74 | /// /// 判断是否完成选择 75 | /// if (_cascadeController.isCompleted()) { 76 | /// List selectedTitles = _cascadeController.selectedTitles; 77 | /// List selectedIndexes = _cascadeController.selectedIndexes; 78 | /// } 79 | /// } 80 | /// ) 81 | ``` 82 | 83 | ## Demo代码 84 | > 可以直接用[test_page.dart][4]进行测试 85 | 86 | demo使用了以下树形结构数据: 87 | ```dart 88 | class Item { 89 | String? name; 90 | String? code; 91 | String? fatherCode; 92 | String? remark; 93 | List? children; 94 | } 95 | ``` 96 | 需要自己提取数据中要显示的标题。页数从1开始,选中项从0开始。 97 | ```dart 98 | nextPageData: (pageCallback, currentPage, selectIndex) async { 99 | if (currentPage == 1) { 100 | // 在第一页选中,返回第二页列表数据 101 | List? nextPageData = items[selectIndex] 102 | .children?.map((e) => e.name!).toList(); 103 | if (nextPageData != null) pageCallback(nextPageData); 104 | } else if (currentPage == 2) { 105 | // 在第二页选中,返回第三页列表数据 106 | // 先获取已选中的序号 107 | List selectedIndexes = _cascadeController.selectedIndexes; 108 | // 根据已选中的序号在items中获取下一级页面的列表数据 109 | List? nextPageData = items[selectedIndexes[0]] 110 | .children?[selectIndex] 111 | .children?.map((e) => e.name!).toList(); 112 | if (nextPageData != null) pageCallback(nextPageData); 113 | } 114 | }, 115 | ``` 116 | 117 |
118 | 119 | ## 效果 120 | 121 | | 1 | 2 | 122 | | --- | --- | 123 | | ![Demo 1][1] | ![demo 2][2] 124 | 125 | [1]:https://github.com/xionghaoo/assets/blob/master/cascade_picker_1.png?raw=true 126 | [2]:https://github.com/xionghaoo/assets/blob/master/cascade_picker_2.gif?raw=true 127 | [3]:https://github.com/xionghaoo/cascade_picker/blob/master/images/ic_select_mark.png?raw=true 128 | [4]:https://github.com/xionghaoo/cascade_picker/blob/master/demo/test_page.dart 129 | [5]:https://github.com/xionghaoo/cascade_picker/blob/master/demo/demo.jpeg?raw=true -------------------------------------------------------------------------------- /demo/test_page.dart: -------------------------------------------------------------------------------- 1 | import 'package:cascade_picker/cascade_picker.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class TestPage extends StatefulWidget { 5 | const TestPage({Key? key}) : super(key: key); 6 | 7 | @override 8 | _TestPageState createState() => _TestPageState(); 9 | } 10 | 11 | class _TestPageState extends State { 12 | final _cascadeController = CascadeController(); 13 | 14 | late List items; 15 | 16 | @override 17 | void initState() { 18 | super.initState(); 19 | 20 | items = []; 21 | for (int i = 0; i < 5; i++) { 22 | Item item0 = Item(); 23 | item0.name = "name_$i"; 24 | item0.code = "code_$i"; 25 | List children1 = []; 26 | for (int j = 0; j < 3; j++) { 27 | Item item1 = Item(); 28 | item1.name = "name_${i}_$j"; 29 | item1.code = "code_${i}_$j"; 30 | List children2 = []; 31 | for (int k = 0; k < 7; k++) { 32 | Item item2 = Item(); 33 | item2.name = "name_${i}_${j}_$k"; 34 | item2.code = "code_${i}_${j}_$k"; 35 | // 第3页没有子数据列表 36 | item2.children = []; 37 | children2.add(item2); 38 | } 39 | // 第2页的子数据列表 40 | item1.children = children2; 41 | children1.add(item1); 42 | } 43 | // 第1页的子数据列表 44 | item0.children = children1; 45 | items.add(item0); 46 | } 47 | } 48 | 49 | @override 50 | Widget build(BuildContext context) { 51 | return Scaffold( 52 | body: Center( 53 | child: Column( 54 | children: [ 55 | SizedBox(height: 50,), 56 | TextButton( 57 | onPressed: () { 58 | if (_cascadeController.isCompleted()) { 59 | // 已选中的titles 60 | List selectedTitles = _cascadeController.selectedTitles; 61 | print("已选中的titles: $selectedTitles"); 62 | // 已选中的序号 63 | List selectedIndexes = _cascadeController.selectedIndexes; 64 | print("已选中的序号:$selectedIndexes"); 65 | 66 | Item item = items[selectedIndexes[0]].children![selectedIndexes[1]].children![selectedIndexes[2]]; 67 | print("已选择item( ${item.name} )"); 68 | } 69 | }, 70 | child: Text("选定") 71 | ), 72 | SizedBox(height: 10,), 73 | Container( 74 | height: 400, 75 | child: CascadePicker( 76 | initialPageData: items.map((e) => e.name!).toList(), 77 | nextPageData: (pageCallback, currentPage, selectIndex) async { 78 | print("当前选择: 第$currentPage页, 第$selectIndex项"); 79 | if (currentPage == 1) { 80 | // 在第一页选中,返回第二页列表数据 81 | List? nextPageData = items[selectIndex] 82 | .children?.map((e) => e.name!).toList(); 83 | if (nextPageData != null) pageCallback(nextPageData); 84 | } else if (currentPage == 2) { 85 | // 在第二页选中,返回第二页列表数据 86 | // 先获取已选中的序号 87 | List selectedIndexes = _cascadeController.selectedIndexes; 88 | // 根据已选中的序号在items中获取下一级页面的列表数据 89 | List? nextPageData = items[selectedIndexes[0]] 90 | .children?[selectIndex] 91 | .children?.map((e) => e.name!).toList(); 92 | if (nextPageData != null) pageCallback(nextPageData); 93 | } 94 | }, 95 | controller: _cascadeController, 96 | maxPageNum: 3, 97 | // selectedIcon: Image.asset("images/ic_select_mark.png", width: 10, height: 10, color: Colors.redAccent,), 98 | ), 99 | ), 100 | ], 101 | ), 102 | ), 103 | ); 104 | } 105 | } 106 | 107 | class Item { 108 | String? name; 109 | String? code; 110 | String? fatherCode; 111 | String? remark; 112 | List? children; 113 | } 114 | -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | async: 5 | dependency: transitive 6 | description: 7 | name: async 8 | sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" 9 | url: "https://pub.dev" 10 | source: hosted 11 | version: "2.11.0" 12 | boolean_selector: 13 | dependency: transitive 14 | description: 15 | name: boolean_selector 16 | sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" 17 | url: "https://pub.dev" 18 | source: hosted 19 | version: "2.1.1" 20 | characters: 21 | dependency: transitive 22 | description: 23 | name: characters 24 | sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" 25 | url: "https://pub.dev" 26 | source: hosted 27 | version: "1.3.0" 28 | clock: 29 | dependency: transitive 30 | description: 31 | name: clock 32 | sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf 33 | url: "https://pub.dev" 34 | source: hosted 35 | version: "1.1.1" 36 | collection: 37 | dependency: transitive 38 | description: 39 | name: collection 40 | sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a 41 | url: "https://pub.dev" 42 | source: hosted 43 | version: "1.18.0" 44 | fake_async: 45 | dependency: transitive 46 | description: 47 | name: fake_async 48 | sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" 49 | url: "https://pub.dev" 50 | source: hosted 51 | version: "1.3.1" 52 | flutter: 53 | dependency: "direct main" 54 | description: flutter 55 | source: sdk 56 | version: "0.0.0" 57 | flutter_test: 58 | dependency: "direct dev" 59 | description: flutter 60 | source: sdk 61 | version: "0.0.0" 62 | matcher: 63 | dependency: transitive 64 | description: 65 | name: matcher 66 | sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" 67 | url: "https://pub.dev" 68 | source: hosted 69 | version: "0.12.16" 70 | material_color_utilities: 71 | dependency: transitive 72 | description: 73 | name: material_color_utilities 74 | sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" 75 | url: "https://pub.dev" 76 | source: hosted 77 | version: "0.5.0" 78 | meta: 79 | dependency: transitive 80 | description: 81 | name: meta 82 | sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e 83 | url: "https://pub.dev" 84 | source: hosted 85 | version: "1.10.0" 86 | path: 87 | dependency: transitive 88 | description: 89 | name: path 90 | sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" 91 | url: "https://pub.dev" 92 | source: hosted 93 | version: "1.8.3" 94 | sky_engine: 95 | dependency: transitive 96 | description: flutter 97 | source: sdk 98 | version: "0.0.99" 99 | source_span: 100 | dependency: transitive 101 | description: 102 | name: source_span 103 | sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" 104 | url: "https://pub.dev" 105 | source: hosted 106 | version: "1.10.0" 107 | stack_trace: 108 | dependency: transitive 109 | description: 110 | name: stack_trace 111 | sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" 112 | url: "https://pub.dev" 113 | source: hosted 114 | version: "1.11.1" 115 | stream_channel: 116 | dependency: transitive 117 | description: 118 | name: stream_channel 119 | sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 120 | url: "https://pub.dev" 121 | source: hosted 122 | version: "2.1.2" 123 | string_scanner: 124 | dependency: transitive 125 | description: 126 | name: string_scanner 127 | sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" 128 | url: "https://pub.dev" 129 | source: hosted 130 | version: "1.2.0" 131 | term_glyph: 132 | dependency: transitive 133 | description: 134 | name: term_glyph 135 | sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 136 | url: "https://pub.dev" 137 | source: hosted 138 | version: "1.2.1" 139 | test_api: 140 | dependency: transitive 141 | description: 142 | name: test_api 143 | sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" 144 | url: "https://pub.dev" 145 | source: hosted 146 | version: "0.6.1" 147 | vector_math: 148 | dependency: transitive 149 | description: 150 | name: vector_math 151 | sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" 152 | url: "https://pub.dev" 153 | source: hosted 154 | version: "2.1.4" 155 | web: 156 | dependency: transitive 157 | description: 158 | name: web 159 | sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 160 | url: "https://pub.dev" 161 | source: hosted 162 | version: "0.3.0" 163 | sdks: 164 | dart: ">=3.2.0-194.0.dev <4.0.0" 165 | flutter: ">=1.20.0" 166 | -------------------------------------------------------------------------------- /lib/cascade_picker.dart: -------------------------------------------------------------------------------- 1 | library cascade_picker; 2 | 3 | import 'package:flutter/material.dart'; 4 | 5 | /// 级联选择器 6 | /// 使用示例: 7 | /// ```dart 8 | /// CascadePicker的page是ListView,没有约束的情况下它的高度是无限的, 9 | /// 因此需要约束高度。 10 | /// 11 | /// final _cascadeController = CascadeController(); 12 | /// 13 | /// initialPageData: 第一页的数据 14 | /// nextPageData: 下一页的数据,点击当前页的选择项后调用该方法加载下一页 15 | /// - pageCallback: 用于传递下一页的数据给CascadePicker 16 | /// - currentPage: 当前是第几页 17 | /// - selectIndex: 当前选中第几项 18 | /// controller: 控制器,用于获取已选择的数据 19 | /// maxPageNum: 最大页数 20 | /// selectedIcon: 已选中选项前面的图标,flutter package不能放本地资源文件,因此需要从外部传入,图标在images文件夹下面 21 | /// 22 | /// Expand( 23 | /// child: CascadePicker( 24 | /// initialPageData: ['a', 'b', 'c', 'd'], 25 | /// nextPageData: (pageCallback, currentPage, selectIndex) async { 26 | /// pageCallback(['one', 'two', 'three']) 27 | /// }, 28 | /// controller: _cascadeController, 29 | /// maxPageNum: 4, 30 | /// selectedIcon: Image.asset("images/ic_select_mark.png", width: 10, height: 10, color: Colors.redAccent,), 31 | /// ) 32 | /// 33 | /// InkBox( 34 | /// child: Container(...) 35 | /// onTap: () { 36 | /// /// 判断是否完成选择 37 | /// if (_cascadeController.isCompleted()) { 38 | /// List selectedTitles = _cascadeController.selectedTitles; 39 | /// List selectedIndexes = _cascadeController.selectedIndexes; 40 | /// } 41 | /// } 42 | /// ) 43 | /// ``` 44 | 45 | /// pageData: 下一页的数据 46 | /// currentPage: 当前是第几页, 47 | /// selectIndex: 当前页选中第几项 48 | typedef void NextPageCallback(Function(List) pageData, int currentPage, int selectIndex); 49 | 50 | class CascadePicker extends StatefulWidget { 51 | 52 | final List initialPageData; 53 | final NextPageCallback nextPageData; 54 | final int maxPageNum; 55 | final CascadeController controller; 56 | final Color tabColor; 57 | final double tabHeight; 58 | final TextStyle tabTitleStyle; 59 | final double itemHeight; 60 | final TextStyle itemTitleStyle; 61 | final Color itemColor; 62 | final Widget? selectedIcon; 63 | 64 | CascadePicker({ 65 | required this.initialPageData, 66 | required this.nextPageData, 67 | this.maxPageNum = 3, 68 | required this.controller, 69 | this.tabHeight = 40, 70 | this.tabColor = Colors.white, 71 | this.tabTitleStyle = const TextStyle(color: Colors.black, fontSize: 14), 72 | this.itemHeight = 40, 73 | this.itemColor = Colors.white, 74 | this.itemTitleStyle = const TextStyle(color: Colors.black, fontSize: 14), 75 | this.selectedIcon 76 | }); 77 | 78 | @override 79 | _CascadePickerState createState() => _CascadePickerState(this.controller); 80 | } 81 | 82 | class _CascadePickerState extends State with SingleTickerProviderStateMixin { 83 | 84 | static String _newTabName = "请选择"; 85 | 86 | final CascadeController _cascadeController; 87 | 88 | _CascadePickerState(this._cascadeController) { 89 | _cascadeController._setState(this); 90 | } 91 | 92 | late final AnimationController _controller; 93 | late final CurvedAnimation _curvedAnimation; 94 | Animation? _sliderAnimation; 95 | final _sliderFixMargin = ValueNotifier(0.0); 96 | double _sliderWidth = 20; 97 | 98 | PageController _pageController = PageController(initialPage: 0); 99 | 100 | GlobalKey _sliderKey = GlobalKey(); 101 | List _tabKeys = []; 102 | 103 | /// 选择器数据集合 104 | List> _pagesData = []; 105 | /// 已选择的title集合 106 | List _selectedTabs = [_newTabName]; 107 | /// 已选择的item index集合 108 | List _selectedIndexes = [-1]; 109 | 110 | /// "请选择"tab宽度,添加新的tab时用到 111 | double _animTabWidth = 0; 112 | /// tab添加事件记录,用于隐藏"请选择"tab初始化状态 113 | bool _isAddTabEvent = false; 114 | /// tab移动未开始,渲染'请选择'tab时隐藏文本,这时的tab在终点位置 115 | bool _isAnimateTextHide = false; 116 | 117 | /// 防止_moveSlider重复调用 118 | bool _isClickAndMoveTab = false; 119 | /// 当前选择的页面,移动滑块前赋值 120 | int _currentSelectPage = 0; 121 | 122 | _addTab(int page, int atIndex, String currentPageItem) { 123 | _loadNextPageData(page, atIndex, currentPageItem); 124 | } 125 | 126 | _loadNextPageData(int page, int atIndex, String currentPageItem, {bool isUpdatePage = false}) { 127 | widget.nextPageData((data) { 128 | final nextPageDataIsEmpty = data.isEmpty; 129 | if (!nextPageDataIsEmpty) { 130 | /// 下一页有数据,更新本页数据或添加新的页面 131 | setState(() { 132 | if (isUpdatePage) { 133 | /// 更新下一页 134 | _pagesData[page] = data; 135 | _selectedTabs[page] = _newTabName; 136 | _selectedIndexes[page] = -1; 137 | /// 清空下下页以后的所有页面和tab数据 138 | _pagesData.removeRange(page + 1, _pagesData.length); 139 | _selectedIndexes.removeRange(page + 1, _selectedIndexes.length); 140 | _selectedTabs.removeRange(page + 1, _selectedTabs.length); 141 | } else { 142 | /// 添加新的页面 143 | _isAnimateTextHide = true; 144 | _isAddTabEvent = true; 145 | _pagesData.add(data); 146 | _selectedTabs.add(_newTabName); 147 | _selectedIndexes.add(-1); 148 | } 149 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 150 | _moveSlider(page, isAdd: true); 151 | }); 152 | }); 153 | } else { 154 | /// 如果下一页数据为空,那么更新本页数据 155 | final currentPage = page - 1; 156 | setState(() { 157 | _selectedTabs[currentPage] = currentPageItem; 158 | _selectedIndexes[currentPage] = atIndex; 159 | /// 下一页数据为空,清空下一页以后的所有页面和tab数据 160 | _pagesData.removeRange(page, _pagesData.length); 161 | _selectedIndexes.removeRange(page, _selectedIndexes.length); 162 | _selectedTabs.removeRange(page, _selectedTabs.length); 163 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 164 | // 调整滑块位置 165 | _moveSlider(currentPage); 166 | }); 167 | }); 168 | } 169 | }, page, atIndex); 170 | } 171 | 172 | _moveSlider(int page, {bool movePage = true, bool isAdd = false}) { 173 | if (movePage && _currentSelectPage != page) { 174 | /// 上一次选择的页面和本次选择的页面不同时,移动tab标签, 175 | /// 移动时先把_isClickAndMoveTab设为true,防止滑动PageView 176 | /// 时_moveSlider重复调用。 177 | _isClickAndMoveTab = true; 178 | } 179 | _isAddTabEvent = isAdd; 180 | _currentSelectPage = page; 181 | 182 | if (_controller.isAnimating) { 183 | _controller.stop(); 184 | } 185 | RenderBox slider = _sliderKey.currentContext?.findRenderObject() as RenderBox; 186 | Offset sliderPosition = slider.localToGlobal(Offset.zero); 187 | RenderBox currentTabBox = _tabKeys[page].currentContext?.findRenderObject() as RenderBox; 188 | Offset currentTabPosition = currentTabBox.localToGlobal(Offset.zero); 189 | 190 | _animTabWidth = currentTabBox.size.width; 191 | 192 | final begin = sliderPosition.dx - _sliderFixMargin.value; 193 | final end = currentTabPosition.dx + (currentTabBox.size.width - _sliderWidth) / 2 - _sliderFixMargin.value; 194 | _sliderAnimation = Tween(begin: begin, end: end).animate(_curvedAnimation); 195 | _controller.value = 0; 196 | _controller.forward(); 197 | if (movePage) { 198 | _pageController.animateToPage(page, curve: Curves.linear, duration: Duration(milliseconds: 500)); 199 | } 200 | } 201 | 202 | /// 注意:tab渲染完成才开始动画,即调用moveSlider,这个方法会在动画执行期间多次调用 203 | Widget _animateTab({required Widget tab}) { 204 | return Transform.translate( 205 | offset: Offset(Tween(begin: _isAddTabEvent ? -_animTabWidth : 0, end: 0).evaluate(_curvedAnimation), 0), 206 | child: Opacity( 207 | /// 动画未开始前隐藏文本 208 | opacity: _isAnimateTextHide ? 0 : 1, 209 | child: tab 210 | ), 211 | ); 212 | } 213 | 214 | List _tabWidgets() { 215 | List widgets = []; 216 | _tabKeys.clear(); 217 | for (int i = 0; i < _pagesData.length; i++) { 218 | GlobalKey key = GlobalKey(); 219 | _tabKeys.add(key); 220 | final tab = GestureDetector( 221 | child: Container( 222 | key: key, 223 | height: widget.tabHeight, 224 | color: widget.tabColor, 225 | alignment: Alignment.center, 226 | padding: EdgeInsets.symmetric(horizontal: 15), 227 | child: ConstrainedBox( 228 | constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width / _pagesData.length - 10), 229 | child: Text( 230 | _selectedTabs[i], 231 | style: _currentSelectPage == i ? widget.tabTitleStyle.copyWith(color: Colors.redAccent) : widget.tabTitleStyle, 232 | maxLines: 1, 233 | overflow: TextOverflow.ellipsis, 234 | ), 235 | ), 236 | ), 237 | onTap: () { 238 | _moveSlider(i); 239 | }, 240 | ); 241 | if (i == _pagesData.length - 1 && _selectedTabs[i] == _newTabName) { 242 | widgets.add(_animateTab(tab: tab)); 243 | _isAnimateTextHide = false; 244 | } else { 245 | widgets.add(tab); 246 | } 247 | } 248 | return widgets; 249 | } 250 | 251 | /// 选择项 252 | Widget _pageItemWidget(int index, int page, String item) { 253 | return GestureDetector( 254 | child: Container( 255 | alignment: Alignment.centerLeft, 256 | padding: EdgeInsets.symmetric(horizontal: 15), 257 | height: widget.itemHeight, 258 | color: widget.itemColor, 259 | child: Row( 260 | children: [ 261 | item == _selectedTabs[page] 262 | ? Padding( 263 | padding: const EdgeInsets.all(5.0), 264 | child: widget.selectedIcon == null 265 | ? Icon(Icons.chevron_right, size: 15, color: Colors.redAccent) 266 | : widget.selectedIcon, 267 | ) 268 | : SizedBox(), 269 | Text( 270 | "$item", 271 | style: item == _selectedTabs[page] 272 | ? widget.itemTitleStyle.copyWith(color: Colors.redAccent) 273 | : widget.itemTitleStyle 274 | ), 275 | ], 276 | ), 277 | ), 278 | onTap: () { 279 | if (page == widget.maxPageNum - 1) { 280 | /// 当前页是最后一页 281 | setState(() { 282 | _selectedTabs[page] = item; 283 | _selectedIndexes[page] = index; 284 | /// 调整滑块位置 285 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 286 | _moveSlider(page); 287 | }); 288 | }); 289 | } else if (_tabKeys.length >= widget.maxPageNum || page < _tabKeys.length - 1) { 290 | if (index == _selectedIndexes[page]) { 291 | /// 选择相同的item 292 | _moveSlider(page + 1); 293 | } else { 294 | /// 选择不同的item,更新tab renderBox 295 | setState(() { 296 | _selectedTabs[page] = item; 297 | _selectedIndexes[page] = index; 298 | // _selectedIndexes.removeRange(page + 1, _selectedIndexes.length); 299 | }); 300 | _loadNextPageData(page + 1, index, item, isUpdatePage: true); 301 | } 302 | } else { 303 | /// 添加新tab页面 304 | /// page == _tabKeys.length - 1 && _tabKeys.length == widget.maxPageNum 305 | _selectedTabs[page] = item; 306 | _selectedIndexes[page] = index; 307 | _addTab(page + 1, index, item); 308 | } 309 | }, 310 | ); 311 | } 312 | 313 | Widget _pageWidget(int page) { 314 | return ListView.builder( 315 | padding: EdgeInsets.zero, 316 | itemCount: _pagesData[page].length, 317 | itemBuilder: (context, index) => _pageItemWidget(index, page, _pagesData[page][index]), 318 | // separatorBuilder: (context, index) => Divider(height: 0.3, thickness: 0.3, color: Color(0xffdddddd), indent: 15, endIndent: 15,), 319 | ); 320 | } 321 | 322 | @override 323 | void initState() { 324 | super.initState(); 325 | _pagesData.add(widget.initialPageData); 326 | 327 | _controller = AnimationController( 328 | duration: const Duration(milliseconds: 500), 329 | vsync: this 330 | ); 331 | 332 | _curvedAnimation = CurvedAnimation( 333 | parent: _controller, 334 | curve: Curves.ease 335 | )..addStatusListener((state) { 336 | }); 337 | 338 | _sliderAnimation = Tween(begin: 0, end: 10).animate(_curvedAnimation); 339 | 340 | WidgetsBinding.instance.addPostFrameCallback((timeStamp) { 341 | RenderBox tabBox = _tabKeys.first.currentContext?.findRenderObject() as RenderBox; 342 | _sliderFixMargin.value = (tabBox.size.width - _sliderWidth) / 2; 343 | }); 344 | } 345 | 346 | @override 347 | Widget build(BuildContext context) { 348 | return Column( 349 | crossAxisAlignment: CrossAxisAlignment.start, 350 | children: [ 351 | AnimatedBuilder( 352 | animation: _sliderAnimation!, 353 | builder: (context, child) => Stack( 354 | clipBehavior: Clip.hardEdge, 355 | alignment: Alignment.bottomLeft, 356 | children: [ 357 | Container( 358 | width: MediaQuery.of(context).size.width, 359 | child: Row( 360 | children: _tabWidgets(), 361 | ), 362 | ), 363 | ValueListenableBuilder( 364 | valueListenable: _sliderFixMargin, 365 | builder: (_, margin, __) => Positioned( 366 | left: margin + _sliderAnimation!.value, 367 | child: Container( 368 | key: _sliderKey, 369 | width: _sliderWidth, 370 | height: 2, 371 | decoration: BoxDecoration( 372 | color: Colors.redAccent, 373 | borderRadius: BorderRadius.circular(2) 374 | ), 375 | ), 376 | ), 377 | ) 378 | ], 379 | ), 380 | ), 381 | Expanded( 382 | child: PageView.builder( 383 | itemCount: _pagesData.length, 384 | controller: _pageController, 385 | itemBuilder: (context, index) => _pageWidget(index), 386 | onPageChanged: (position) { 387 | if (!_isClickAndMoveTab) { 388 | _moveSlider(position, movePage: false); 389 | } 390 | if (_currentSelectPage == position) { 391 | _isClickAndMoveTab = false; 392 | } 393 | }, 394 | ), 395 | ) 396 | ], 397 | ); 398 | } 399 | } 400 | 401 | class CascadeController { 402 | late final _CascadePickerState _state; 403 | 404 | _setState(_CascadePickerState state) { 405 | _state = state; 406 | } 407 | 408 | List get selectedTitles => _state._selectedTabs; 409 | 410 | List get selectedIndexes => _state._selectedIndexes; 411 | 412 | bool isCompleted() => !_state._selectedTabs.contains(_CascadePickerState._newTabName); 413 | } 414 | --------------------------------------------------------------------------------