Flutter象棋:5.添加基本界面

到目前,我们的「棋路-中国象棋」游戏已经有些眉目了!

为了不至于太紧张,在做了一大堆复杂工作后,现在是做点轻松且有成效的工作的时间了!本节让我们来给象棋游戏添加基本的界面元素!

本节提要

  • 添加游戏主菜单页
  • 布局对战页面
  • 配置全屏/竖屏模式
  • 讨论不同屏幕的适配

我们将在游戏中使用全屏界面,考虑到目前手机中有大量的刘海屏存在,所以我们再 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上看看:

iPhone 11 Pro 上的运行效果

在 Android 设备上看看:

Android 上的运行效果

在 iPad 上运行产品看看:

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 为基准,讨论了细长屏幕以及偏宽的屏幕的应用对方式。这样的解决方法不能算作是技术方案,更多的应该算是设计解决方案!

现实世界中,没有一种理论或方法永远有效的;就像手机屏幕有尺寸、纵横比总不会一致一样!在设计中,这些被称为设计限制因素。设计不是艺术创作,是在「指尖跳舞蹈」— 在多种限制因素下寻找平衡方案的游戏!