原創研發flutter3+getX+mediaKit仿抖音app短視頻直播實戰Flutter-DouYin。 flutter3_dylive使用最新跨平臺技術flutter3.x+dart3+getx+get_storage+media_kit開發手機端仿抖音app小視頻直播實戰項目。實現了抖音全屏 ...
原創研發flutter3+getX+mediaKit仿抖音app短視頻直播實戰Flutter-DouYin。
flutter3_dylive使用最新跨平臺技術flutter3.x+dart3+getx+get_storage+media_kit開發手機端仿抖音app小視頻直播實戰項目。實現了抖音全屏式上下滑動視頻、左右滑動切換頁面模塊,直播間進場/禮物動畫,聊天等模塊。
技術棧
- 編輯器:Vscode
- 技術框架:Flutter3.19.2+Dart3.3.0
- 路由/狀態管理:get: ^4.6.6
- 本地緩存:get_storage: ^2.1.1
- 圖片預覽插件:photo_view: ^0.14.0
- 刷新載入:easy_refresh^3.3.4
- toast輕提示:toast^0.3.0
- 視頻套件:media_kit: ^1.1.10+1
flutter_douyin實現了類似抖音全屏沉浸式滑動效果(上下滑動視頻、左右切換頁面模塊)。
如下圖:實現了左右滑動的同時,頂部狀態欄+Tab菜單+底部bottomNavigationBar導航欄三者聯動效果。
項目結構目錄
在項目創建前期,需要配置好Flutter SDK和Dart SDK開發環境。
如果是使用Vscode編輯器開發flutter項目,大家需自行配置flutter/dart擴展語法插件。
項目中的一些技術知識點,有些是在之前的項目flutter3聊天實例中有介紹過,感興趣的話可以去看看。
https://www.cnblogs.com/xiaoyan2017/p/18008370
https://www.cnblogs.com/xiaoyan2017/p/18048244
Flutter入口配置main.dart
import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:media_kit/media_kit.dart'; import 'utils/index.dart'; // 引入佈局模板 import 'layouts/index.dart'; import 'binding/binding.dart'; // 引入路由管理 import 'router/index.dart'; void main() async { // 初始化get_storage await GetStorage.init(); // 初始化media_kit WidgetsFlutterBinding.ensureInitialized(); MediaKit.ensureInitialized(); runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return GetMaterialApp( title: 'FLUTTER3 DYLIVE', debugShowCheckedModeBanner: false, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFFE2C55)), useMaterial3: true, // 修正windows端字體粗細不一致 fontFamily: Platform.isWindows ? 'Microsoft YaHei' : null, ), home: const Layout(), // 全局綁定GetXController initialBinding: GlobalBindingController(), // 初始路由 initialRoute: Utils.isLogin() ? '/' : '/login', // 路由頁面 getPages: routePages, // 錯誤路由 // unknownRoute: GetPage(name: '/404', page: Error), ); } }
Flutter3底部導航欄
底部導航欄使用 bottomNavigationBar 組件實現頁面模塊切換。通過getx全局狀態來聯動控制底部導航欄背景顏色。
導航欄中間圖標/圖片按鈕,使用了 Positioned 組件定位實現功能。
return Scaffold( backgroundColor: Colors.grey[50], body: pageList[pageCurrent], // 底部導航欄 bottomNavigationBar: Theme( // Flutter去掉BottomNavigationBar底部導航欄的水波紋 data: ThemeData( splashColor: Colors.transparent, highlightColor: Colors.transparent, hoverColor: Colors.transparent, ), child: Obx(() { return Stack( children: [ Container( decoration: const BoxDecoration( border: Border(top: BorderSide(color: Colors.black54, width: .1)), ), child: BottomNavigationBar( backgroundColor: bottomNavigationBgcolor(), fixedColor: FStyle.primaryColor, unselectedItemColor: bottomNavigationItemcolor(), type: BottomNavigationBarType.fixed, elevation: 1.0, unselectedFontSize: 12.0, selectedFontSize: 12.0, currentIndex: pageCurrent, items: [ ...pageItems ], onTap: (index) { setState(() { pageCurrent = index; }); }, ), ), // 自定義底部導航欄中間按鈕 Positioned( left: MediaQuery.of(context).size.width / 2 - 15, top: 0, bottom: 0, child: InkWell( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Icon(Icons.tiktok, color: bottomNavigationItemcolor(centerDocked: true), size: 32.0,), Image.asset('assets/images/applogo.png', width: 32.0, fit: BoxFit.contain,) // Text('直播', style: TextStyle(color: bottomNavigationItemcolor(centerDocked: true), fontSize: 12.0),) ], ), onTap: () { setState(() { pageCurrent = 2; }); }, ), ), ], ); }), ), );
import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../styles/index.dart'; import '../../controllers/page_video_controller.dart'; // 引入pages頁面 import '../pages/index/index.dart'; import '../pages/video/index.dart'; import '../pages/live/index.dart'; import '../pages/message/index.dart'; import '../pages/my/index.dart'; class Layout extends StatefulWidget { const Layout({super.key}); @override State<Layout> createState() => _LayoutState(); } class _LayoutState extends State<Layout> { PageVideoController pageVideoController = Get.put(PageVideoController()); // page索引 int pageCurrent = 0; // page頁面 List pageList = [const Index(), const FVideo(), const FLiveList(), const Message(), const My()]; // tabs選項 List pageItems = [ const BottomNavigationBarItem( icon: Icon(Icons.home_outlined), label: '首頁' ), const BottomNavigationBarItem( icon: Icon(Icons.play_arrow_outlined), label: '短視頻' ), const BottomNavigationBarItem( icon: Icon(Icons.live_tv_rounded, color: Colors.transparent,), label: '' ), BottomNavigationBarItem( icon: Stack( alignment: const Alignment(4, -2), children: [ const Icon(Icons.messenger_outline), FStyle.badge(1) ], ), label: '消息' ), BottomNavigationBarItem( icon: Stack( alignment: const Alignment(1.5, -1), children: [ const Icon(Icons.person_outline), FStyle.badge(0, isdot: true) ], ), label: '我' ) ]; // 底部導航欄背景色 Color bottomNavigationBgcolor() { int index = pageCurrent; int pageVideoTabIndex = pageVideoController.pageVideoTabIndex.value; Color color = Colors.white; if(index == 1) { if([1, 2, 3].contains(pageVideoTabIndex)) { color = Colors.white; }else { color = Colors.black; } } return color; } // 底部導航欄顏色 Color bottomNavigationItemcolor({centerDocked = false}) { int index = pageCurrent; int pageVideoTabIndex = pageVideoController.pageVideoTabIndex.value; Color color = Colors.black54; if(index == 1) { if([1, 2, 3].contains(pageVideoTabIndex)) { color = Colors.black54; }else { color = Colors.white60; } }else if(index == 2 && centerDocked) { color = FStyle.primaryColor; } return color; } // ... }
Flutter實現抖音全屏式滑動
如下圖:實現類似抖音左右滑動全屏切換頁面模塊、上下滑動絲滑切換短視頻。
採用 TabBar 和 PageView 聯動頁面滑動。
return Scaffold( extendBodyBehindAppBar: true, appBar: AppBar( forceMaterialTransparency: true, backgroundColor: [1, 2, 3].contains(pageVideoController.pageVideoTabIndex.value) ? null : Colors.transparent, foregroundColor: [1, 2, 3].contains(pageVideoController.pageVideoTabIndex.value) ? Colors.black : Colors.white, titleSpacing: 1.0, leading: Obx(() => IconButton(icon: Icon(Icons.menu, color: tabColor(),), onPressed: () {},),), title: Obx(() { return TabBar( controller: tabController, tabs: pageTabs.map((v) => Tab(text: v)).toList(), isScrollable: true, tabAlignment: TabAlignment.center, overlayColor: MaterialStateProperty.all(Colors.transparent), unselectedLabelColor: unselectedTabColor(), labelColor: tabColor(), indicatorColor: tabColor(), indicatorSize: TabBarIndicatorSize.label, unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'), labelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.w600), dividerHeight: 0, labelPadding: const EdgeInsets.symmetric(horizontal: 10.0), indicatorPadding: const EdgeInsets.symmetric(horizontal: 5.0), onTap: (index) { pageVideoController.updatePageVideoTabIndex(index); // 更新索引 pageController.jumpToPage(index); }, ); }), actions: [ Obx(() => IconButton(icon: Icon(Icons.search, color: tabColor(),), onPressed: () {},),), ], ), body: Column( children: [ Expanded( child: Stack( children: [ /// 水平滾動模塊 PageView( // 自定義滾動行為(支持桌面端滑動、去掉滾動條槽) scrollBehavior: PageScrollBehavior().copyWith(scrollbars: false), scrollDirection: Axis.horizontal, controller: pageController, onPageChanged: (index) { pageVideoController.updatePageVideoTabIndex(index); // 更新索引 setState(() { tabController.animateTo(index); }); }, children: [ ...pageModules ], ), ], ), ), ], ), );
PageVideoController pageVideoController = Get.put(PageVideoController()); List<String> pageTabs = ['熱點', '長視頻', '文旅', '商城', '關註', '同城服務', '推薦']; final pageModules = [ const HotModule(), const LongVideoModule(), const TripModule(), const MallModule(), const FavorModule(), const NearModule(), const RecommendModule() ]; late final TabController tabController = TabController(initialIndex: pageVideoController.pageVideoTabIndex.value, length: pageTabs.length, vsync: this); // 頁面controller late final PageController pageController = PageController(initialPage: pageVideoController.pageVideoTabIndex.value, viewportFraction: 1.0); @override void dispose() { tabController.dispose(); pageController.dispose(); super.dispose(); }
flutter短視頻mini進度條
底部mini播放進度條一開始是採用自定義組件實現效果,不過不能拖動滾動條進度,後來就採用了Slider組件來實現。
// flutter滑動短視頻模塊 Q:282310962 return Container( color: Colors.black, child: Column( children: [ Expanded( child: Stack( children: [ /// 垂直滾動模塊 PageView.builder( // 自定義滾動行為(支持桌面端滑動、去掉滾動條槽) scrollBehavior: PageScrollBehavior().copyWith(scrollbars: false), scrollDirection: Axis.vertical, controller: pageController, onPageChanged: (index) async { ... }, itemCount: videoList.length, itemBuilder: (context, index) { return Stack( children: [ // 視頻區域 Positioned( top: 0, left: 0, right: 0, bottom: 0, child: GestureDetector( child: Stack( children: [ // 短視頻插件 Visibility( visible: videoIndex == index, child: Video( controller: videoController, fit: BoxFit.cover, // 無控制條 controls: NoVideoControls, ), ), // 播放/暫停按鈕 StreamBuilder( stream: player.stream.playing, builder: (context, playing) { return Visibility( visible: playing.data == false, child: Center( child: IconButton( padding: EdgeInsets.zero, onPressed: () { player.playOrPause(); }, icon: Icon( playing.data == true ? Icons.pause : Icons.play_arrow_rounded, color: Colors.white70, size: 70, ), ), ), ); }, ), ], ), onTap: () { player.playOrPause(); }, ), ), // 右側操作欄 Positioned( bottom: 15.0, right: 10.0, child: Column( ... ), ), // 底部信息區域 Positioned( bottom: 15.0, left: 10.0, right: 80.0, child: Column( ... ), ), // 播放mini進度條 Positioned( bottom: 0.0, left: 10.0, right: 10.0, child: Visibility( visible: videoIndex == index && position > Duration.zero, child: Listener( child: SliderTheme( data: const SliderThemeData( trackHeight: 2.0, thumbShape: RoundSliderThumbShape(enabledThumbRadius: 4.0), // 調整滑塊的大小 // trackShape: RectangularSliderTrackShape(), // 使用矩形軌道形狀 overlayShape: RoundSliderOverlayShape(overlayRadius: 0), // 去掉Slider預設上下邊距間隙 inactiveTrackColor: Colors.white24, // 設置非活動進度條的顏色 activeTrackColor: Colors.white, // 設置活動進度條的顏色 thumbColor: Colors.pinkAccent, // 設置滑塊的顏色 overlayColor: Colors.transparent, // 設置滑塊覆蓋層的顏色 ), child: Slider( value: sliderValue, onChanged: (value) async { // debugPrint('當前視頻播放時間$value'); setState(() { sliderValue = value; }); // 跳轉播放時間 await player.seek(duration * value.clamp(0.0, 1.0)); }, onChangeEnd: (value) async { setState(() { sliderDraging = false; }); // 繼續播放 if(!player.state.playing) { await player.play(); } }, ), ), onPointerMove: (e) { setState(() { sliderDraging = true; }); }, ), ), ), // 視頻播放時間 Positioned( bottom: 90.0, left: 10.0, right: 10.0, child: Visibility( visible: sliderDraging, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text(position.label(reference: duration), style: const TextStyle(color: Colors.white, fontSize: 16.0, fontWeight: FontWeight.w600),), Container( margin: const EdgeInsets.symmetric(horizontal: 7.0), child: const Text('/', style: TextStyle(color: Colors.white54, fontSize: 10.0,),), ), Text(duration.label(reference: duration), style: const TextStyle(color: Colors.white54, fontSize: 16.0, fontWeight: FontWeight.w600),), ], ), ), ), ], ); }, ), /// 固定層 // 紅包 Positioned( left: 15.0, top: MediaQuery.of(context).padding.top + 20, child: Container( height: 40.0, width: 40.0, decoration: BoxDecoration( color: Colors.black12, borderRadius: BorderRadius.circular(100.0), ), child: UnconstrainedBox( child: Image.asset('assets/images/time-hb.png', width: 30.0, fit: BoxFit.contain,), ), ), ), ], ), ), ], ), );
flutter3直播模塊
flutter直播模塊包含了頂部信息、底部進場提示+商品層+彈幕層+操作欄等幾個模塊。
// 商品購買動效 Container( ... ), // 加入直播間動效 const AnimationLiveJoin( joinQueryList: [ {'avatar': 'assets/images/logo.png', 'name': 'andy'}, {'avatar': 'assets/images/logo.png', 'name': 'jack'}, {'avatar': 'assets/images/logo.png', 'name': '一條鹹魚'}, {'avatar': 'assets/images/logo.png', 'name': '四季平安'}, {'avatar': 'assets/images/logo.png', 'name': '葉子'}, ], ), // 送禮物動效 const AnimationLiveGift( giftQueryList: [ {'label': '小心心', 'gift': 'assets/images/gift/gift1.png', 'user': 'Jack', 'avatar': 'assets/images/avatar/uimg2.jpg', 'num': 12}, {'label': '棒棒糖', 'gift': 'assets/images/gift/gift2.png', 'user': 'Andy', 'avatar': 'assets/images/avatar/uimg6.jpg', 'num': 36}, {'label': '大啤酒', 'gift': 'assets/images/gift/gift3.png', 'user': '一條鹹魚', 'avatar': 'assets/images/avatar/uimg1.jpg', 'num': 162}, {'label': '人氣票', 'gift': 'assets/images/gift/gift4.png', 'user': 'Flower', 'avatar': 'assets/images/avatar/uimg5.jpg', 'num': 57}, {'label': '鮮花', 'gift': 'assets/images/gift/gift5.png', 'user': '四季平安', 'avatar': 'assets/images/avatar/uimg3.jpg', 'num': 6}, {'label': '捏捏小臉', 'gift': 'assets/images/gift/gift6.png', 'user': 'Alice', 'avatar': 'assets/images/avatar/uimg4.jpg', 'num': 28}, {'label': '你真好看', 'gift': 'assets/images/gift/gift7.png', 'user': '葉子', 'avatar': 'assets/images/avatar/uimg7.jpg', 'num': 95}, {'label': '親吻', 'gift': 'assets/images/gift/gift8.png', 'user': 'YOYO', 'avatar': 'assets/images/avatar/uimg8.jpg', 'num': 11}, {'label': '玫瑰', 'gift': 'assets/images/gift/gift12.png', 'user': '宇輝', 'avatar': 'assets/images/avatar/uimg9.jpg', 'num': 3}, {'label': '私人飛機', 'gift': 'assets/images/gift/gift16.png', 'user': 'Hison', 'avatar': 'assets/images/avatar/uimg10.jpg', 'num': 273}, ], ), // 直播彈幕+商品講解 Container( margin: const EdgeInsets.only(top: 7.0), height: 200.0, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: ListView.builder( padding: EdgeInsets.zero, itemCount: liveJson[index]['message']?.length, itemBuilder: (context, i) => danmuList(liveJson[index]['message'])[i], ), ), SizedBox( width: isVisibleGoodsTalk ? 7 : 35, ), // 商品講解 Visibility( visible: isVisibleGoodsTalk, child: Column( ... ), ), ], ), ), // 底部工具欄 Container( margin: const EdgeInsets.only(top: 7.0), child: Row( ... ), ),
直播聊天彈幕通過Flexible配合Text.rich / TextSpan實現消息自適應佈局界面。
通過 SlideTransition 組件實現直播進場動畫。
return SlideTransition( position: animationFirst ? animation : animationMix, child: Container( alignment: Alignment.centerLeft, margin: const EdgeInsets.only(top: 7.0), padding: const EdgeInsets.symmetric(horizontal: 7.0,), height: 23.0, width: 250, decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ Color(0xFF6301FF), Colors.transparent ], ), borderRadius: BorderRadius.horizontal(left: Radius.circular(10.0)), ), child: joinList!.isNotEmpty ? Text('歡迎 ${joinList![0]['name']} 加入直播間', style: const TextStyle(color: Colors.white, fontSize: 14.0,),) : Container() , ), );
class _AnimationLiveJoinState extends State<AnimationLiveJoin> with TickerProviderStateMixin { // 動畫控制器 late AnimationController controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 500), // 第一個動畫持續時間 ); late AnimationController controllerMix = AnimationController( vsync: this, duration: const Duration(milliseconds: 1000), // 第二個動畫持續時間 ); // 動畫 late Animation<Offset> animation = Tween(begin: const Offset(2.5, 0), end: const Offset(0, 0)).animate(controller); late Animation<Offset> animationMix = Tween(begin: const Offset(0, 0), end: const Offset(-2.5, 0)).animate(controllerMix); Timer? timer; // 是否第一個動畫 bool animationFirst = true; // 是否空閑 bool idle = true; // 加入直播間數據列表 List? joinList; @override void initState() { super.initState(); joinList = widget.joinQueryList!.toList(); runAnimation(); animation.addListener(() { if(animation.status == AnimationStatus.forward) { debugPrint('第一個動畫進行中'); idle = false; setState(() {}); }else if(animation.status == AnimationStatus.completed) { debugPrint('第一個動畫結束'); animationFirst = false; if(controllerMix.isCompleted || controllerMix.isDismissed) { timer = Timer(const Duration(seconds: 2), () { controllerMix.forward(); debugPrint('第二個動畫開始'); }); } setState(() {}); } }); animationMix.addListener(() { if(animationMix.status == AnimationStatus.forward) { setState(() {}); }else if(animationMix.status == AnimationStatus.completed) { animationFirst = true; controller.reset(); controllerMix.reset(); if(joinList!.isNotEmpty) { joinList!.removeAt(0); } idle = true; // 執行下一個數據 runAnimation(); setState(() {}); } }); } void runAnimation() { if(joinList!.isNotEmpty) { // 空閑狀態才能執行,防止添加數據播放狀態混淆 if(idle == true) { if(controller.isCompleted || controller.isDismissed) { setState(() {}); timer = Timer(Duration.zero, () { controller.forward(); }); } } } } @override void dispose() { controller.dispose(); controllerMix.dispose(); timer?.cancel(); super.dispose(); } }
綜上就是flutter3實戰開發仿抖音的一些知識分享,由於涉及的技術點還是蠻多的,就沒有非常詳細的展開介紹了。希望以上的一些分享能給大家有些幫助~
最後附上兩個跨端項目
uniapp+vue3短視頻直播商城:https://www.cnblogs.com/xiaoyan2017/p/17938517
tauri+vite4中後臺管理系統:https://www.cnblogs.com/xiaoyan2017/p/17552562
本文為博主原創文章,未經博主允許不得轉載,歡迎大家一起交流 QQ(282310962) wx(xy190310)