Flutter TV 应用的开发尝试 | 开发者说·DTalk
我们之前一直在做 Flutter 在移动端的应用,今天这里我们拓展一下 Flutter 的应用场景,我们将拓展到 TV 应用的开发上来。我们知道目前的智能电视和机顶盒都是基于 Android 系统的,所以一般的 TV 应用开发都是采用 Android 原生进行开发,Google 对 Android TV 的开发也进行了一些规范和库的制定。当然也有的是采用的 B/S 架构进行设计的。这里我们将进行尝试 Flutter 开发 TV 应用。虽然写出来了,效果也还可以,体验流畅,自动适配,但其中按键监听、焦点处理和焦点框处理比较麻烦。由于 Google 官方并没有推出 Flutter TV 应用的 SDK,所以我们这里只是给大家拓展下思路。接下来,就分享下其中的技术点。本文将主要介绍:
Flutter TV 应用开发主要难点
Flutter TV 应用开发按键监听
Flutter TV 应用开发焦点处理
Flutter TV 应用开发焦点框效果处理
Flutter TV 应用开发主要难点
//焦点处理
android:focusable="true"
//触摸模式下是否可以点击,可选可不选
android:focusableInTouchMode="true"
Flutter TV 应用开发按键监听
const RawKeyboardListener({ Key key, @required this.focusNode,//焦点结点 @required this.onKey,//按键接收处理事件 @required this.child,//接收焦点的子控件 })
很简单给个例子:
FocusNode focusNode0 = FocusNode();
... ...
RawKeyboardListener( focusNode: focusNode0, child: Container( decoration: getCircleDecoration(color0), child: Padding( child: Card( elevation: 5, shape: CircleBorder(), child: CircleAvatar( child: Text(''), backgroundImage: AssetImage("assets/icon_tv.png"), radius: radius, ), ), padding: EdgeInsets.all(padding), ), ), onKey: (RawKeyEvent event) { if (event is RawKeyDownEvent && event.data is RawKeyEventDataAndroid) { RawKeyDownEvent rawKeyDownEvent = event; RawKeyEventDataAndroid rawKeyEventDataAndroid = rawKeyDownEvent.data; print("keyCode: ${rawKeyEventDataAndroid.keyCode}"); switch (rawKeyEventDataAndroid.keyCode) { case 19: //KEY_UP FocusScope.of(context).requestFocus(_focusNode); break; case 20: //KEY_DOWN break; case 21: //KEY_LEFT FocusScope.of(context).requestFocus(focusNode4); break; case 22: //KEY_RIGHT FocusScope.of(context).requestFocus(focusNode1); break; case 23: //KEY_CENTER break; case 66: //KEY_ENTER break; default: break; } } }, )
Flutter TV 应用开发焦点处理
FocusNode focusNode0 = FocusNode();
... ...
//主动获取焦点
FocusScope.of(context).requestFocus(focusNode0);
//自动获取焦点
FocusScope.of(context).autofocus(focusNode0);
DefaultFocusTraversal
进行自动指定方向进行搜索下一个焦点:
FocusScope.of(context) .focusInDirection(TraversalDirection.up);
// 或者像下面这样使用
DefaultFocusTraversal.of(context).inDirection( FocusScope.of(context).focusedChild, TraversalDirection.up);
DefaultFocusTraversal.of(context) .inDirection(_focusNode, TraversalDirection.right);
支持上下左右四个方向。如果想手动指定下一个焦点是哪个的话,可以像下面这样用:
FocusScope.of(context).requestFocus(focusNode);
Flutter TV 应用开发焦点框效果处理
var default_decoration = BoxDecoration( border: Border.all(width: 3, color: Colors.deepOrange), borderRadius: BorderRadius.all( Radius.circular(5), ));
... ...
child: Container( margin: EdgeInsets.all(8), decoration: default_decoration, child: widget.child, ));
最后给大家一个完整的最新的技术方案的例子代码:
先绘制欢迎页,效果图如下:
代码如下:
// 启动欢迎页
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'ui/tv_page.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { SystemChrome.setEnabledSystemUIOverlays([]); // 强制横屏 SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight ]); return MaterialApp( title: 'Flutter TV', debugShowCheckedModeBanner: false, theme: ThemeData( primarySwatch: Colors.blue, ), home: MyHomePage(), ); }
}
class MyHomePage extends StatefulWidget { @override _MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> { Timer timer; @override void initState() { startTimeout(); super.initState(); } @override Widget build(BuildContext context) { return Scaffold( primary: true, backgroundColor: Colors.black54, body: Center( child: Text( '芒果TV', style: TextStyle( fontSize: 50, color: Colors.deepOrange, fontWeight: FontWeight.normal), ), ), ); } _toPage() { Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (context) => TVPage()), (route) => route == null, ); } //倒计时处理 static const timeout = const Duration(seconds: 3); startTimeout() { timer = Timer(timeout, handleTimeout); return timer; } void handleTimeout() { _toPage(); } @override void dispose() { if (timer != null) { timer.cancel(); timer = null; } super.dispose(); }
}
应用首页,效果图如下:
代码如下:
// 应用首页
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tv/utils/time_utils.dart';
import 'package:flutter_tv/widgets/tv_widget.dart';
import 'home_page.dart';
import 'list_page.dart';
class TVPage extends StatefulWidget { @override State<StatefulWidget> createState() { SystemChrome.setEnabledSystemUIOverlays([]); // 强制横屏 SystemChrome.setPreferredOrientations( [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); return TVPageState(); }
}
class TVPageState extends State<TVPage> with SingleTickerProviderStateMixin { TabController _tabController; Timer timer; var timeString = TimeUtils.getTime(); bool init = false; FocusNode focusNodeB0 = FocusNode(); FocusNode focusNodeB1 = FocusNode(); @override void initState() { super.initState(); //initialIndex为初始选中第几个,length为数量 _tabController = TabController(initialIndex: 0, length: 8, vsync: this); // 监听 _tabController.addListener(() { switch (_tabController.index) { case 0: break; case 1: break; } }); focusNodeB0.addListener(() { if (focusNodeB0.hasFocus) { setState(() { _tabController.animateTo(0); }); } }); focusNodeB1.addListener(() { if (focusNodeB1.hasFocus) { setState(() { _tabController.animateTo(1); }); } }); } @override Widget build(BuildContext context) { return Container( color: Colors.black87, padding: EdgeInsets.all(30), child: Scaffold( appBar: AppBar( backgroundColor: Colors.black87, leading: Icon( Icons.live_tv, color: Colors.deepOrange, size: 50, ), title: Text( '芒果TV', style: TextStyle( fontSize: 30, color: Colors.white, fontStyle: FontStyle.italic), ), primary: true, actions: <Widget>[ FlatButton( child: Text( '$timeString', style: TextStyle(color: Colors.white), ), ), ], // 设置TabBar bottom: TabBar( controller: _tabController, indicatorColor: Colors.deepOrange, labelColor: Colors.deepOrange, unselectedLabelColor: Colors.white, tabs: <Widget>[ Tab( child: TVWidget( hasDecoration: false, focusChange: (hasFocus) { if (hasFocus) { setState(() { _tabController.animateTo(0); }); } }, child: Text( '首页', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), requestFocus: true, ), ), Tab( child: TVWidget( hasDecoration: false, focusChange: (hasFocus) { if (hasFocus) { setState(() { _tabController.animateTo(1); }); } }, child: Text( '精选', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), )), Tab( child: TVWidget( hasDecoration: false, focusChange: (hasFocus) { if (hasFocus) { setState(() { _tabController.animateTo(2); }); } }, onclick: () { print("点击"); }, child: Text( '国产', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), )), Tab( child: TVWidget( hasDecoration: false, focusChange: (hasFocus) { if (hasFocus) { setState(() { _tabController.animateTo(3); }); } }, child: Text( '欧美', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), )), Tab( child: TVWidget( hasDecoration: false, focusChange: (hasFocus) { if (hasFocus) { setState(() { _tabController.animateTo(4); }); } }, child: Text( '日漫', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), ), ), Tab( child: TVWidget( hasDecoration: false, focusChange: (hasFocus) { if (hasFocus) { setState(() { _tabController.animateTo(5); }); } }, child: Text( '亲子', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), ), ), Tab( child: TVWidget( hasDecoration: false, focusChange: (hasFocus) { if (hasFocus) { setState(() { _tabController.animateTo(6); }); } }, child: Text( '少综', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), ), ), Tab( child: TVWidget( focusChange: (hasFocus) { if (hasFocus) { setState(() { _tabController.animateTo(7); }); } }, hasDecoration: false, child: Text( '分类', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), ), ), ], ), ), body: TabBarView( controller: _tabController, children: <Widget>[ HomePage(), ListPage(), HomePage(), ListPage(), HomePage(), ListPage(), HomePage(), ListPage(), ], ), ), ); } startTimeout() { timer = Timer.periodic(Duration(minutes: 1), (t) { setState(() { timeString = TimeUtils.getTime(); }); }); } @override void dispose() { if (timer != null) { timer.cancel(); timer == null; } super.dispose(); }
}
// TAB页面中的其中一个页面,其他类似
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_tv/widgets/tv_widget.dart';
class HomePage extends StatefulWidget { const HomePage({ Key key, @required this.index, }) : super(key: key); final int index; @override State<StatefulWidget> createState() { return HomePageState(); }
}
class HomePageState extends State<HomePage> with AutomaticKeepAliveClientMixin { @override void initState() { super.initState(); } @override Widget build(BuildContext context) { return Container( color: Colors.black87, child: Row( children: <Widget>[ Flexible( child: Column( children: <Widget>[ _buildItem(0), _buildItem(1), _buildItem(2), ], ), flex: 1, ), Flexible( child: Column( children: <Widget>[ _buildImageItem(3, 2), Expanded( flex: 1, child: Row( children: <Widget>[ _buildImageItem(4, 1), _buildImageItem(5, 1), ], )), ], ), flex: 4, ), Flexible( child: Column( children: <Widget>[ _buildImageItem(6, 2), _buildImageItem(7, 1), ], ), flex: 2, ), Flexible( child: Column( children: <Widget>[ _buildImageItem(8, 2), _buildImageItem(9, 1), ], ), flex: 2, ), ], ), ); } _buildItem(int index) { return Expanded( child: TVWidget( focusChange: (hasfocus) {}, child: Container( width: MediaQuery.of(context).size.width, child: GestureDetector( child: Card( elevation: 5, margin: EdgeInsets.all(0), color: _colors.elementAt(index), child: Container( child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ _icons.elementAt(index), _title.elementAt(index), ], ), ), ), onTap: () { _click(index); }, ), )), flex: 1, ); } _buildImageItem(int index, int flex) { return Expanded( child: TVWidget( child: Container( width: MediaQuery.of(context).size.width, child: GestureDetector( child: Card( elevation: 5, margin: EdgeInsets.all(0), color: _colors.elementAt(index), child: Container( child: Stack( alignment: Alignment.bottomLeft, children: <Widget>[ ClipRRect( child: Image.asset( _images.elementAt(index), fit: BoxFit.fill, width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).size.height, ), borderRadius: BorderRadius.all( Radius.circular(5), ), ), Container( width: MediaQuery.of(context).size.width, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ _title.elementAt(index), index == 3 ? _des.elementAt(index) : SizedBox( height: 0, ), ], ), color: _colors.elementAt(index).withAlpha(240), padding: EdgeInsets.all(5), ), ], ), ), ), onTap: () { _click(index); }, ), ), focusChange: (hasfocus) {}, ), flex: flex, ); } void _click(int index) { switch (index) { case 0: break; case 4:
// Navigator.push(context, MaterialPageRoute(builder: (context) {
// return AboutPage();
// })); break; } } List<Icon> _icons = [ Icon( Icons.search, size: 38, color: Colors.white, ), Icon( Icons.history, size: 38, color: Colors.white, ), Icon( Icons.event, size: 38, color: Colors.white, ), Icon( Icons.share, size: 38, color: Colors.deepPurpleAccent, ), Icon( Icons.error_outline, size: 38, color: Colors.orange, ), Icon( Icons.settings, size: 38, color: Colors.red, ) ]; List<String> _images = [ 'assets/htpy.jpg', 'assets/htpy.jpg', 'assets/htpy.jpg', 'assets/htpy.jpg', 'assets/agzz.jpg', 'assets/amypj.jpg', 'assets/hmjz.jpg', 'assets/dxflqm.jpg', 'assets/lifeandpi.jpg', 'assets/nanasqc.jpg', ]; List<Color> _colors = [ Colors.red, Colors.orange, Colors.green, Colors.red, Colors.orange, Colors.green, Colors.orange, Colors.orange, Colors.orange, Colors.orange, ]; List<Text> _title = [ Text( "搜索", style: TextStyle(color: Colors.white, fontSize: 18), ), Text( "历史", style: TextStyle(color: Colors.white, fontSize: 18), ), Text( "专题", style: TextStyle(color: Colors.white, fontSize: 18), ), Text( "环太平洋", style: TextStyle(color: Colors.white, fontSize: 18), ), Text( "阿甘正传", style: TextStyle(color: Colors.white, fontSize: 18), ), Text( "傲慢与偏见", style: TextStyle(color: Colors.white, fontSize: 18), ), Text( "黑猫警长", style: TextStyle(color: Colors.white, fontSize: 18), ), Text( "当幸福来敲门", style: TextStyle(color: Colors.white, fontSize: 18), ), Text( "Life Or PI", style: TextStyle(color: Colors.white, fontSize: 18), ), Text( "哪啊哪啊神去村", style: TextStyle(color: Colors.white, fontSize: 18), ), ]; List<Text> _des = [ Text( "非常好看的电影", style: TextStyle(color: Colors.white, fontSize: 12), ), Text( "设置密码锁", style: TextStyle(color: Colors.white, fontSize: 12), ), Text( "吐槽反馈你的想法", style: TextStyle(color: Color.fromRGBO(162, 162, 162, 1), fontSize: 16), ), Text( "非常好看的电影", style: TextStyle(color: Colors.white, fontSize: 12), ), Text( "版本信息", style: TextStyle(color: Color.fromRGBO(162, 162, 162, 1), fontSize: 16), ), Text( "系统相关设置", style: TextStyle(color: Color.fromRGBO(162, 162, 162, 1), fontSize: 16), ), Text( "系统相关设置", style: TextStyle(color: Color.fromRGBO(162, 162, 162, 1), fontSize: 16), ), ]; @override // TODO: implement wantKeepAlive bool get wantKeepAlive => true;
}
封装的核心类:
// 封装的核心焦点处理类
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class TVWidget extends StatefulWidget { TVWidget( {Key key, @required this.child, @required this.focusChange, @required this.onclick, @required this.decoration, @required this.hasDecoration = true, @required this.requestFocus = false}) : super(key: key); Widget child; onFocusChange focusChange; onClick onclick; bool requestFocus; BoxDecoration decoration; bool hasDecoration; @override State<StatefulWidget> createState() { return TVWidgetState(); }
}
typedef void onFocusChange(bool hasFocus);
typedef void onClick();
class TVWidgetState extends State<TVWidget> { FocusNode _focusNode; bool init = false; var default_decoration = BoxDecoration( border: Border.all(width: 3, color: Colors.deepOrange), borderRadius: BorderRadius.all( Radius.circular(5), )); var decoration = null; @override void initState() { super.initState(); _focusNode = FocusNode(); _focusNode.addListener(() { if (widget.focusChange != null) { widget.focusChange(_focusNode.hasFocus); } if (_focusNode.hasFocus) { setState(() { if (widget.hasDecoration) { decoration = widget.decoration == null ? default_decoration : widget.decoration; } }); } else { setState(() { decoration = null; }); } }); } @override Widget build(BuildContext context) { if (widget.requestFocus && !init) { FocusScope.of(context).requestFocus(_focusNode); init = true; } return RawKeyboardListener( focusNode: _focusNode, onKey: (event) { if (event is RawKeyDownEvent && event.data is RawKeyEventDataAndroid) { RawKeyDownEvent rawKeyDownEvent = event; RawKeyEventDataAndroid rawKeyEventDataAndroid = rawKeyDownEvent.data; print("keyCode: ${rawKeyEventDataAndroid.keyCode}"); switch (rawKeyEventDataAndroid.keyCode) { case 19: //KEY_UP
// DefaultFocusTraversal.of(context).inDirection(
// FocusScope.of(context).focusedChild, TraversalDirection.up); FocusScope.of(context) .focusInDirection(TraversalDirection.up); break; case 20: //KEY_DOWN FocusScope.of(context) .focusInDirection(TraversalDirection.down); break; case 21: //KEY_LEFT
// FocusScope.of(context).requestFocus(focusNodeB0); FocusScope.of(context) .focusInDirection(TraversalDirection.left); // 手动指定下一个焦点 // FocusScope.of(context).requestFocus(focusNode); break; case 22: //KEY_RIGHT
// FocusScope.of(context).requestFocus(focusNodeB1); FocusScope.of(context) .focusInDirection(TraversalDirection.right);
// DefaultFocusTraversal.of(context)
// .inDirection(_focusNode, TraversalDirection.right);
// if(_focusNode.nextFocus()){
// FocusScope.of(context)
// .focusInDirection(TraversalDirection.right);
// } break; case 23: //KEY_CENTER widget.onclick(); break; case 66: //KEY_ENTER widget.onclick(); break; default: break; } } }, child: Container( margin: EdgeInsets.all(8), decoration: decoration, child: widget.child, )); }
}
总结
"开发者说·DTalk" 面向中国开发者们征集 Google 移动应用 (apps & games) 相关的产品/技术内容。欢迎大家前来分享您对移动应用的行业洞察或见解、移动开发过程中的心得或新发现、以及应用出海的实战经验总结和相关产品的使用反馈等。我们由衷地希望可以给这些出众的中国开发者们提供更好展现自己、充分发挥自己特长的平台。我们将通过大家的技术内容着重选出优秀案例进行谷歌开发技术专家 (GDE) 的推荐。
点击屏末 | 阅读原文 | 了解更多 "开发者说·DTalk" 活动详情与参与方式
长按右侧二维码
报名参与
Flutter TV 应用的开发尝试 | 开发者说·DTalk相关推荐
- 已开源!Flutter 流畅度优化组件 Keframe | 开发者说·DTalk
本文原作者: Nayuta,原文发布于: 进击的 Flutter 列表流畅度优化 这是一个通用的流畅度优化方案,通过分帧渲染优化由构建导致的卡顿,例如页面切换或者复杂列表快速滚动的场景. 代码中 ex ...
- 不一样角度带您了解 Flutter 中的滑动列表实现 | 开发者说·DTalk
本文原作者: 恋猫de小郭,原文发布于: GSYTech 本篇主要帮助剖析理解 Flutter 里的列表和滑动的组成,用比较通俗易懂的方式,从常见的 ListView 到 NestedScrollV ...
- 如何理解 Flutter 路由源码设计?| 开发者说·DTalk
本文原作者: Nayuta,原文发布于: 进击的 Flutter 本期看点: 70 行代码实现一个丐版路由 路由源码细节解析 导语 某天在公众号看到这样一个问题 这问题我熟啊,刚好翻译 Overl ...
- 拓展交流空间,分享开发精彩 | 开发者说·DTalk 鉴赏
日月其迈,岁律更新,时间的洗礼让开发者们更加坚韧,持续探索,不断追求,同样也激励着我们为开发者提供更多的帮助与支持.不断迭代的技术产品是开发者们的趁手工具,定期更新的政策助力打造安全可靠的生态,透彻易 ...
- 第 4-2 课:开发一个 Flutter TV 应用
之前一直在讲 Flutter 在移动端的应用尝试,今天这节内容,我们将拓展到 TV 应用的开发上来. 我们知道目前的智能电视和机顶盒都是基于 Android 系统的,所以一般的 TV 应用开发都是采用 ...
- Flutter 混合开发和组件化实践 | 开发者说·DTalk
本文原作者: 李伟,原文发布于: 印象笔记 https://app.yinxiang.com/fx/0390f0f2-1770-4bdc-a3c4-d134a6bc654b 引言 在接入 Flutte ...
- html5 开发tv,乐视TV开放平台鼎力支持html5开发者
[赛迪网讯]8月14日消息,随着2013年html5峰会圆满召开,乐视对html5开发者的支持进入了一个新的阶段.在过去十年中,互联网为全球范围内的技术创新和开发提供了前所未有的机遇.目前互联网逐渐进 ...
- 字节跳动为什么选用 Flutter : 可能成为不一样的未来 | 开发者说·DTalk
本文原作者: 袁辉辉,原文发布于微信公众号: Gityuan https://mp.weixin.qq.com/s/SaIAQ22gbB4nJsDQj3QwrQ 2018 年 12 月,Google ...
- 谁告诉你 Flutter 会干掉原生开发?
如下图所示,今天刚好收到一个知乎问题,而刚刚好这个视频我也在抖音上看到过,播放量还不低,本来是"一笑而过"但是既然这么"有缘份",那就关于 "Flut ...
最新文章
- 大盘点|6D姿态估计算法汇总(上)
- php files上传错误,php-PHP上传文件的问题$_FILES['file']['error']
- 升级php影响zabbix吗,zabbix2.0升级到zabbix3.0
- 光进铜退下的“更高”与“更低”,锐捷发布企业极简以太全光网解决方案
- 触发器-当表1插入数据时将表1的数据插入表2
- rust怎么用items刷东西_rust服主怎么刷东西 | 手游网游页游攻略大全
- transition.tween
- MySQL高可用之MHA的搭建 转
- Zookeeper 入门示例
- 工作完成了,切勿激动,一定要先求证
- 数据结构(C语言版 第2版)课后习题答案 严蔚敏 编著
- 线性代数导论20——克莱姆法则、逆矩阵、体积
- c语言编程串行静态数码显示实验,十天学会单片机和C语言编程-数码管动态扫描显示.ppt...
- html5电商销售网站统计后台模板html5电商销售网站统计后台模板
- 对抗神经网络学习和实现(GAN)
- 阿尔伯塔大学计算机科学是哪个校区,阿尔伯塔大学优势专业是什么?
- css小猫笑起来的动画
- HDU 4889 Scary Path Finding Algorithm
- APP上查个人信用报告靠谱吗?
- 前端框架 — Bootstrap
热门文章
- 专家看台:阿里软件产品设计师成长之路
- 为什么重写equals方法就一定要重写hashCode方法
- 苹果手机一直黑屏转圈_苹果手机电池容量很小,为何一直不做出改变?原因也很明显...
- CDA学习-----数据结构
- 结巴分词1.8.2版本源代码解析(一)
- 华林证券:下半年或重回熊市调整之路
- PAT基础编程题目集 7-32 说反话-加强版
- 模电——三极管的三种基本组态对比
- 高可用架构:CAP原理
- 嬴政完美 Windows2000/XP/2003 All in one 2009 贺岁版