我们之前一直在做 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"
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;
}
}
},
)
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);
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 {
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 createState() {
SystemChrome.setEnabledSystemUIOverlays([]);
// 强制横屏
SystemChrome.setPreferredOrientations(
[DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
return TVPageState();
}
}
class TVPageState extends State 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: [
FlatButton(
child: Text(
'$timeString',
style: TextStyle(color: Colors.white),
),
),
],
// 设置TabBar
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.deepOrange,
labelColor: Colors.deepOrange,
unselectedLabelColor: Colors.white,
tabs: [
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: [
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 createState() {
return HomePageState();
}
}
class HomePageState extends State with AutomaticKeepAliveClientMixin {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black87,
child: Row(
children: [
Flexible(
child: Column(
children: [
_buildItem(0),
_buildItem(1),
_buildItem(2),
],
),
flex: 1,
),
Flexible(
child: Column(
children: [
_buildImageItem(3, 2),
Expanded(
flex: 1,
child: Row(
children: [
_buildImageItem(4, 1),
_buildImageItem(5, 1),
],
)),
],
),
flex: 4,
),
Flexible(
child: Column(
children: [
_buildImageItem(6, 2),
_buildImageItem(7, 1),
],
),
flex: 2,
),
Flexible(
child: Column(
children: [
_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: [
_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: [
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: [
_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 _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 _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 _colors = [
Colors.red,
Colors.orange,
Colors.green,
Colors.red,
Colors.orange,
Colors.green,
Colors.orange,
Colors.orange,
Colors.orange,
Colors.orange,
];
List _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 _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 createState() {
return TVWidgetState();
}
}
typedef void onFocusChange(bool hasFocus);
typedef void onClick();
class TVWidgetState extends State {
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" 活动详情与参与方式
长按右侧二维码
报名参与