到目前,我们的「棋路-中国象棋」游戏已经有些眉目了!
为了不至于太紧张,在做了一大堆复杂工作后,现在是做点轻松且有成效的工作的时间了!本节让我们来给象棋游戏添加基本的界面元素!
本节提要
- 添加游戏主菜单页
- 布局对战页面
- 配置全屏/竖屏模式
- 讨论不同屏幕的适配
我们将在游戏中使用全屏界面,考虑到目前手机中有大量的刘海屏存在,所以我们再 ChessApp 中定义一个状态栏高度的命名常量,游戏的每个页面在 StatusBar 的高度范围内都放置内容:
class ChessApp extends StatelessWidget { // static const StatusBarHeight = 28.0; ... }
复制
添加游戏主菜单页
我们先为游戏添加一个开始菜单页面:在 lib/routes 文件夹下,我们创建一个新的文件 main-menu.dart,在其中创建我们的开始菜单 Widget:
import 'package:flutter/material.dart'; import '../common/color-consts.dart'; import '../main.dart'; import 'battle-page.dart'; class MainMenu extends StatelessWidget { // @override Widget build(BuildContext context) { // final nameStyle = TextStyle( fontSize: 64, color: Colors.black, ); final menuItemStyle = TextStyle( fontSize: 28, color: ColorConsts.Primary, ); // 标题及菜单项的布局 final menuItems = Center( child: Column( children:
[ Expanded(child: SizedBox(), flex: 4), Text('中国象棋', style: nameStyle, textAlign: TextAlign.center), Expanded(child: SizedBox()), FlatButton(child: Text('单机对战', style: menuItemStyle), onPressed: () {}), Expanded(child: SizedBox()), FlatButton(child: Text('挑战云主机', style: menuItemStyle),onPressed: () {},), Expanded(child: SizedBox()), FlatButton(child: Text('排行榜', style: menuItemStyle), onPressed: () {}), Expanded(child: SizedBox(), flex: 3), Text('用心娱乐,为爱传承', style: TextStyle(color: Colors.black54, fontSize: 16)), Expanded(child: SizedBox()), ], ), ); // 页面整体 return Scaffold( backgroundColor: ColorConsts.LightBackground, body: Stack( children: [ menuItems, // 为了在页面左上角显示设置按钮,我们使用了基于绝对位置的 Stack 布局 Positioned( top: ChessRoadApp.StatusBarHeight, left: 10, child: IconButton( icon: Icon(Icons.settings, color: ColorConsts.Primary), onPressed: () {}, ), ), ], ), ); } } 复制
这个类的实现比较简单,基本上一行一行的菜单项,代码一目了然!
我们回到 main.dart 文件中来修改一下启动逻辑,让程序一启动就显示 MainMenu 页面,这很简单,只需要把原来的 ChessRoadApp 的 build 方法中的 BattlePage 更换为 MainMenu 就完了!
用系统默认字体来显示应用的名称或菜单,界面看起来会比较呆板,这里先不放图(真心不好看)。我们前文已经在 App 中引入了美观且「中国风」的字体 — 方正启体,现在可以再次派上用场了!
为了不在每个用到文字的地方都单独指定字体名称,有一个小技巧:我们可以为全局指定默认的字体:
在 ChessroadApp 类的 build 方法中可以设置应用的主题,我们在其主题设置中添加 fontFamily 的设置,这样一来应用的全局字体就设置好了,修改后的 main.dart 代码如下:
import './routes/main-menu.dart'; import 'package:flutter/material.dart'; void main() => runApp(ChessRoadApp()); class ChessRoadApp extends StatelessWidget { // static const StatusBarHeight = 28.0; @override Widget build(BuildContext context) { // return MaterialApp( theme: ThemeData(primarySwatch: Colors.brown, fontFamily: 'QiTi'), debugShowCheckedModeBanner: false, home: MainMenu(), ); } }
复制
现在试试在 vscode 中按 F5
看看效果:
页面挺美观的,但会不会太安静和朴素了?
在后续的章节中,我们将为页面添加更丰富的视觉元素和适当的动画效果!
现在点击菜单项的话,会发现什么都没发生,因为我们还没有配置正确的页面导航逻辑。我们找到在 MainMenu 中找到以下代码:
FlatButton(child: Text('挑战云主机', style: menuItemStyle), onPressed: () {}),
复制
将其修改为:
FlatButton( child: Text('挑战云主机', style: menuItemStyle), onPressed: () { Navigator.of(context).push( MaterialPageRoute(builder: (context) => BattlePage()), ); }, ),
复制
现在运行产品可以发现,点击「挑战云主机」菜单,已经可以进入到先前我们一直正在构建的 BattlePage 了。
布置对战页
主菜单的简单 UI 已经正确呈现了,现在我们来调整 BattlePage 的页面布置。
我们先在 _BattlePageState 类中添加 createPageHeader 方法,像它的名称指示的那样,它用来显示对战页面顶部区域:
... class _BattlePageState extends State
{ ... // 标题、活动状态、顶部按钮等 Widget createPageHeader() { // final titleStyle = TextStyle(fontSize: 28, color: ColorConsts.DarkTextPrimary); final subTitleStyle = TextStyle(fontSize: 16, color: ColorConsts.DarkTextSecondary); return Container( margin: EdgeInsets.only(top: ChessRoadApp.StatusBarHeight), child: Column( children: [ Row( children: [ IconButton( icon: Icon(Icons.arrow_back, color: ColorConsts.DarkTextPrimary), onPressed: () {}, ), Expanded(child: SizedBox()), Text('单机对战', style: titleStyle), Expanded(child: SizedBox()), IconButton( icon: Icon(Icons.settings, color: ColorConsts.DarkTextPrimary), onPressed: () {}, ), ], ), Container( height: 4.0, width: 180.0, margin: EdgeInsets.only(bottom: 10), decoration: BoxDecoration( color: ColorConsts.BoardBackground, borderRadius: BorderRadius.circular(2), ), ), Container( padding: EdgeInsets.symmetric(horizontal: 16), child: Text('[游戏状态]', maxLines: 1, style: subTitleStyle), ), ], ), ); } ... } 复制
接着,我们在 _BattlePageState 类中创建 createBoard 方法,这个方法创建 Board 包裹相关的逻辑:
... class _BattlePageState extends State
{ ... Widget createBoard() { // final windowSize = MediaQuery.of(context).size; return Container( margin: EdgeInsets.symmetric( horizontal: BattlePage.BoardMarginH, vertical: BattlePage.BoardMarginV, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), color: ColorConsts.BoardBackground, ), child: BoardWidget( // 棋盘的宽度已经扣除了部分边界 width: windowSize.width - BattlePage.BoardMarginH * 2, onBoardTap: onBoardTap, ), ); } ... } 复制
之后,我们继续在 _BattlePageState 中创建 createOperatorBar 方法,这个方法将在棋盘下边放置一个操作条:
... class _BattlePageState extends State
{ ... // 操作菜单栏 Widget createOperatorBar() { // final buttonStyle = TextStyle(color: ColorConsts.Primary, fontSize: 20); return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), color: ColorConsts.BoardBackground, ), margin: EdgeInsets.symmetric(horizontal: BattlePage.BoardMarginH), padding: EdgeInsets.symmetric(vertical: 2), child: Row(children: [ Expanded(child: SizedBox()), FlatButton(child: Text('新对局', style: buttonStyle), onPressed: () {}), Expanded(child: SizedBox()), FlatButton(child: Text('悔棋', style: buttonStyle), onPressed: () {}), Expanded(child: SizedBox()), FlatButton(child: Text('分析局面', style: buttonStyle), onPressed: () {}), Expanded(child: SizedBox()), ]), ); } ... } 复制
到这里,我们创建了新的 BattlePage 的 Header,创建了 Board,创建了 OperatorBar,剩下的工作只是组装一下这些基础组件:
... class _BattlePageState extends State
{ ... @override Widget build(BuildContext context) { // final header = createPageHeader(); final board = createBoard(); final operatorBar = createOperatorBar(); // 整体页面从上到下,依次排列 return Scaffold( backgroundColor: ColorConsts.DarkBackground, body: Column(children: [header, board, operatorBar]), ); } } 复制
现在在 vscode 中按 F5
试试,BattlePage
页面是不是「有头有脸」了,感觉完全是涅槃重生了!
BattlePage 页面的底部,将来会放置行棋过程的着法列表,现在先空着。
现在 BattlePage 页面左上角的 Back 按钮点击后还不能返回 MainMenu 页面,我们在 createPageHeader 方法中找到下边的代码:
IconButton( icon: Icon(Icons.arrow_back, color: ColorConsts.DarkTextPrimary), onPressed: () {}, ),
复制
将期修改为:
IconButton( icon: Icon(Icons.arrow_back, color: ColorConsts.DarkTextPrimary), // 返回前一页 onPressed: () => Navigator.of(context).pop(), ),
复制
可以运行程序试试,当左上角的 Back 按钮被点击后,将返回到 MainMenu 页面。
让游戏全屏呈现
我们希望象棋游戏的页面能全屏显示,因此我们没有创建 Scafold 中的 AppBar。即使如此,页面顶部依然还显示了系统的任务栏(时间、信号那一条)。
要让 App 界面全屏呈现,我们可以在 main.dart 文件的 main 方法中添加两句代码。
我们在 main.dart 文件中找到下边的代码:
void main() => runApp(ChessApp());
复制
将其修改为下边的样子:
void main() { // runApp(ChessApp()); // 不显示状态栏 if (Platform.isAndroid) { SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle(statusBarColor: Colors.transparent), ); } SystemChrome.setEnabledSystemUIOverlays([]); }
复制
我们的象棋游戏只支持竖屏方式运行,因此我们需要限制运行游戏时的屏幕旋转动作,因此我们还需要在 runApp(ChessApp());
代码行之下,增加下边的限制屏旋转的代码,修改以后的
main 方法代码如下:
void main() { // runApp(ChessRoadApp()); // 仅支持竖屏 SystemChrome.setPreferredOrientations( [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown], ); // 不显示状态栏 if (Platform.isAndroid) { SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle(statusBarColor: Colors.transparent), ); } SystemChrome.setEnabledSystemUIOverlays([]); }
复制
保存并运行程序,看看我们的杰作!
适应更多纵横比的屏幕
到这里,我们的主菜单页面和对战页面基本成型了,老实说:美美的!
但还有一个问题不可忽略,那就是不同屏幕比例的界面兼容问题。
如果每一个设备都拥有相同的屏幕宽度、长度,我们移动开发人员就幸福了!可现实是,用户的设备千差万别,我们必须考虑到用户实际运行的设备是五花八门的,需要为各个页面考虑屏幕兼容问题。
对于主菜单 MainMenu 这样的简单页面,我们使用 Column 模式来布局就有很大的弹性!在垂直方向的各个菜单项之间放置 Expanded 组件,按约定比例分配多余的空间。多数情况下都能很好地解决问题。
而对于我们的 BattlePage 而言,问题要复杂一些。具体来说,我们的游戏垂直方向布局了 Header、Board、OperatorBar 三大组件。棋盘占据了屏幕的中上部分的大多数空间,并且横向占用了全部的屏幕宽度。
由于不同屏幕有不同的纵横比,此页面可能出现以下 3 种情况:
- Case 1:屏幕比较狭长,垂直放置 Header、中间的 Board 和下边的 OperatorBar 后,底部会空出一定空间;
- Case 2:屏幕长宽比适中,垂直放下 Header、中间的 Board 和下边的 OperatorBar 后,刚好用完了屏幕空间;
- Case 3:屏幕稍宽/显短,垂直方向放不下Header、Board 和 OperatorBar 三个部分的内容;
目前市场上最常见屏幕纵横比在 16:9 左右,我们以16:9 为基准,判定用户屏幕为是 Case 1、Case 2 还是 Case 3:
- Case 1:纵横比大于16:9 的屏幕,我们定义为狭长屏;
- Case 2:纵横比为16:9 的横屏为长宽比适中;
- Case 3:纵横屏小于16:9 的屏幕,我们定义为稍宽/显短的屏幕;
对于判断的 3 种类型的屏幕,我们的应对方式如下:
- 对于常见的狭长屏幕,我们在底部空出的空间放置「棋谱」内容,显示走棋的着法列表
- 对于长宽比适中的屏幕,我们仅在底部放置一个按钮,点按按钮后弹出「棋谱」内容
- 对于稍宽/显短的屏幕,我们同步缩小棋盘的宽度和高度,确保能将内容放置完整
我们现在动手来处理屏幕适配问题。我们先将 BattlePage 类顶部原有的两个常量定义删除:
static const BoardMarginV = 10.0, BoardMarginH = 10.0;
复制
在原有位置,添加两个静态常量:
static double boardMargin = 10.0, screenPaddingH = 10.0;
复制
我们在 _BattlePageState 类中添加一个 calcScreenPaddingH 方法,它用来根据屏幕的纵横比,确定在棋盘左右放置多少尺寸的填充:
void calcScreenPaddingH() { // // 当屏幕的纵横比小于16/9时,限制棋盘的宽度 final windowSize = MediaQuery.of(context).size; double height = windowSize.height, width = windowSize.width; if (height / width < 16.0 / 9.0) { width = height * 9 / 16; // 横盘宽度之外的空间,分左右两边,由 screenPaddingH 来持有,布局时添加到 BoardWidget 外围水平边距 BattlePage.screenPaddingH = (windowSize.width - width) / 2 - BattlePage.boardMargin; } }
复制
以于屏幕相对较短的手机,我们在屏幕两侧让出一定的间距,只在中间的纵横比16:9的区域内显示内容。
由于棋盘的纵横比是固定的(接近10:9),当我们在棋盘两侧添加填充距离时,棋盘的高度就相应地变小了,棋盘看起来就呈现出左右居中的效果。
修改 _BattlePageState 类的 build 方法,在方法的开头添加对 calcScreenPaddingH 方法的调用:
@override Widget build(BuildContext context) { // 在 Build 方法中计算宽度 calcScreenPaddingH(); ... }
复制
接着,我们修改 _BattlePageState 的 createBoard 方法,使用上 calcScreenPaddingH 方法的计算结果:
Widget createBoard() { // return Container( margin: EdgeInsets.symmetric( horizontal: BattlePage.screenPaddingH, vertical: BattlePage.boardMargin, ), child: BoardWidget( // 这里将 screenPaddingH 作为边距,放置在 BoardWidget 左右,这样棋盘将水平居中显示 width: MediaQuery.of(context).size.width - BattlePage.screenPaddingH * 2, onBoardTap: onBoardTap, ), ); }
复制
对应的,我们也修改一下 createOperatorBar 方法,使用上 calcScreenPaddingH 计算的结果:
Widget createOperatorBar() { // final buttonStyle = TextStyle(color: ColorConsts.Primary, fontSize: 20); return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), color: ColorConsts.BoardBackground, ), margin: EdgeInsets.symmetric(horizontal: BattlePage.screenPaddingH), padding: EdgeInsets.symmetric(vertical: 2), child: Row(children:
[ Expanded(child: SizedBox()), FlatButton(child: Text('新对局', style: buttonStyle), onPressed: () {}), Expanded(child: SizedBox()), FlatButton(child: Text('悔棋', style: buttonStyle), onPressed: () {}), Expanded(child: SizedBox()), FlatButton(child: Text('分析局面', style: buttonStyle), onPressed: () {}), Expanded(child: SizedBox()), ]), ); } 复制
如前文所述,对于不风纵横比的屏幕,我们在屏幕将布局不一样的内容。对于狭长屏,我们在底部布局一个表示着法列表的「棋谱」区域。其它情况下,我们放置一个按钮,点击后弹出一个对话框展示「棋谱」内容。
现在我们在 _BattlePageState 类中添加 buildFooter 系列方法:
// 对于底部的空间的弹性处理 Widget buildFooter() { // final size = MediaQuery.of(context).size; final manualText = '<暂无棋谱>'; if (size.height / size.width > 16 / 9) { // 长屏幕显示着法列表 return buildManualPanel(manualText); } else { // 短屏幕显示一个按钮,点击它后弹出着法列表 return buildExpandableManaulPanel(manualText); } } // 长屏幕显示着法列表 Widget buildManualPanel(String text) { // final manualStyle = TextStyle( fontSize: 18, color: ColorConsts.DarkTextSecondary, height: 1.5, ); return Expanded( child: Container( margin: EdgeInsets.symmetric(vertical: 16), child: SingleChildScrollView(child: Text(text, style: manualStyle)), ), ); } // 短屏幕显示一个按钮,点击它后弹出着法列表 Widget buildExpandableManaulPanel(String text) { // final manualStyle = TextStyle(fontSize: 18, height: 1.5); return Expanded( child: IconButton( icon: Icon(Icons.expand_less, color: ColorConsts.DarkTextPrimary), onPressed: () => showDialog( context: context, barrierDismissible: false, builder: (BuildContext context) { return AlertDialog( title: Text('棋谱', style: TextStyle(color: ColorConsts.Primary)), content: SingleChildScrollView(child: Text(text, style: manualStyle)), actions:
[ FlatButton( child: Text('好的'), onPressed: () => Navigator.of(context).pop(), ), ], ); }, ), ), ); } 复制
再调整一下 _BattlePageState 类的 build 方法,将 buildFooter 添加到界面布局代码中去:
@override Widget build(BuildContext context) { // calcScreenPaddingH(); final header = createPageHeader(); final board = createBoard(); final operatorBar = createOperatorBar(); final footer = buildFooter(); // 在原有从上到下布局中,添加底部着法列表 return Scaffold( backgroundColor: ColorConsts.DarkBackground, body: Column(children:
[header, board, operatorBar, footer]), ); } 复制
现在在 vscode 中按 `F5` 运行产品看看效果,先在 iphone11上看看:
在 Android 设备上看看:
在 iPad 上运行产品看看:
从上边的不同设备的截图可以看到,我们的屏幕适应策略是有成效的。真是不容易的工作,效果还挺不错!
将代码提交到 git 仓库,本节任务完成!
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git add .
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ git commit -m '添加基本界面'
[master d55e6ca] 添加基本界面
3 files changed, 310 insertions(+), 17 deletions(-)
create mode 100644 lib/routes/main-menu.dart
elapse@elapse-PC:~/Language/Flutter/chinese_chess$ sudo git push
[sudo] elapse 的密码:
Username for 'https://rocketgit.com': elapse
Password for 'https://[email protected]':
枚举对象: 12, 完成.
对象计数中: 100% (12/12), 完成.
使用 4 个线程进行压缩
压缩对象中: 100% (7/7), 完成.
写入对象中: 100% (7/7), 4.37 KiB | 4.37 MiB/s, 完成.
总共 7 (差异 3),复用 0 (差异 0)
remote: RocketGit: Info: == Welcome to RocketGit! ==
remote: RocketGit: Info: you are connecting from IP 27.47.5.58 by http(s).
remote: RocketGit: Info: date/time: 2020-08-26 15:25:11 (UTC), debug id f88fa5.
To https://rocketgit.com/user/elapse/chinese_chess
8acf2c1..d55e6ca master -> master
elapse@elapse-PC:~/Language/Flutter/chinese_chess$
本节回顾
本节课程中,我们添加了开始菜单,使用 Expaneded 组件实现了弹性布局。
接着我们布置了一个很美观的象棋对战页面,它包含了象棋游戏中的标准页面中的的有要素,例如标题,状态栏、棋盘、操作栏,详细信息等。这种版式将来会在其它页面得到复用。
最重要的的一个环节,我们讨论了如果通过布局方案解决不同手机纵横比的问题。我们以纵横 16:9 为基准,讨论了细长屏幕以及偏宽的屏幕的应用对方式。这样的解决方法不能算作是技术方案,更多的应该算是设计解决方案!
现实世界中,没有一种理论或方法永远有效的;就像手机屏幕有尺寸、纵横比总不会一致一样!在设计中,这些被称为设计限制因素。设计不是艺术创作,是在「指尖跳舞蹈」— 在多种限制因素下寻找平衡方案的游戏!