Flutter for OpenHarmony数独游戏App实战:单元格交互与选中
单元格交互与选中的关键设计要点:清晰的视觉反馈(通过颜色区分选中、高亮、冲突等状态)、丰富的手势支持(点击选中、长按笔记、双击清除、拖动多选)、动画效果(选中时的缩放和波纹动画增强交互感)、触觉反馈(不同操作使用不同强度的震动)、键盘导航(支持方向键移动选中位置)、无障碍支持(让所有用户都能使用应用)。单元格交互是数独游戏的基础,良好的交互设计可以让玩家更专注于解题本身,享受数独带来的乐趣。欢迎加
单元格的交互与选中是数独游戏最基础也是最重要的交互之一。玩家需要通过点击选中单元格,然后输入数字。一个好的选中交互不仅要响应迅速,还要提供清晰的视觉反馈,让玩家知道当前选中了哪个单元格。今天我们来详细讲解单元格交互与选中的实现。
选中状态的管理
在设计单元格交互之前,我们需要考虑几个关键问题:选中状态的表示、关联高亮(同行、同列、同宫格)、相同数字高亮。
选中位置的存储
class GameController extends GetxController {
int selectedRow = -1;
int selectedCol = -1;
void selectCell(int row, int col) {
if (row >= 0 && row < 9 && col >= 0 && col < 9) {
selectedRow = row;
selectedCol = col;
update();
}
}
selectedRow和selectedCol记录当前选中的单元格位置,-1表示没有选中任何单元格。selectCell方法首先验证行列索引是否在有效范围内,然后更新选中位置并通知UI更新。这种边界检查可以防止无效的选中操作导致的错误。
构建可点击的单元格
Widget _buildCell(GameController controller, int row, int col) {
bool isSelected = controller.selectedRow == row &&
controller.selectedCol == col;
return GestureDetector(
onTap: () => controller.selectCell(row, col),
child: Container(
decoration: BoxDecoration(
color: _getCellColor(controller, row, col, isSelected),
),
child: Center(
child: _buildCellContent(controller, row, col),
),
),
);
}
GestureDetector包裹整个单元格,使其可以响应点击事件。onTap回调调用controller的selectCell方法。isSelected通过比较当前单元格位置与选中位置来判断。这种设计让每个单元格都是独立的可点击区域。
计算单元格背景色
Color _getCellColor(GameController controller, int row, int col, bool isSelected) {
bool hasConflict = controller.hasConflict(row, col);
if (hasConflict) return Colors.red.shade100;
if (isSelected) return Colors.blue.shade200;
bool isHighlighted = controller.selectedRow == row ||
controller.selectedCol == col ||
_isSameBox(controller.selectedRow,
controller.selectedCol, row, col);
背景色的优先级从高到低:冲突状态(红色)、选中状态(深蓝)。然后检查是否需要关联高亮,即是否与选中单元格在同一行、同一列或同一宫格。
相同数字高亮
int value = controller.board[row][col];
bool isSameNumber = value != 0 &&
controller.selectedRow >= 0 &&
controller.selectedCol >= 0 &&
controller.board[controller.selectedRow]
[controller.selectedCol] == value;
if (isSameNumber) return Colors.blue.shade100;
if (isHighlighted) return Colors.grey.shade200;
return Colors.white;
}
检查当前单元格的数字是否与选中单元格相同。相同数字使用浅蓝色,关联高亮使用灰色,默认白色。这种层次分明的颜色设计让玩家能够快速理解棋盘的当前状态。
判断是否在同一宫格
bool _isSameBox(int selectedRow, int selectedCol, int row, int col) {
if (selectedRow < 0 || selectedCol < 0) return false;
return (selectedRow ~/ 3 == row ~/ 3) && (selectedCol ~/ 3 == col ~/ 3);
}
通过整除运算判断两个单元格是否在同一个3x3宫格内。数独棋盘被分成9个宫格,每个宫格的索引可以通过行列除以3得到。如果两个单元格的宫格索引相同,它们就在同一个宫格内。
带动画的单元格组件
class AnimatedCell extends StatefulWidget {
final int row;
final int col;
final GameController controller;
const AnimatedCell({
super.key,
required this.row,
required this.col,
required this.controller,
});
State<AnimatedCell> createState() => _AnimatedCellState();
}
将单元格提取为独立的StatefulWidget,可以为每个单元格添加独立的动画效果。这种设计让选中时可以有一个轻微的缩放效果,增强交互反馈。
动画控制器设置
class _AnimatedCellState extends State<AnimatedCell>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
}
动画时长100毫秒,缩放从1.0到0.95。使用SingleTickerProviderStateMixin提供vsync,CurvedAnimation添加缓动效果让动画更自然。
点击触发动画
void _onTap() {
_animationController.forward().then((_) {
_animationController.reverse();
});
widget.controller.selectCell(widget.row, widget.col);
}
点击时先播放缩小动画,完成后自动恢复。这种微妙的动画效果可以给玩家即时的触觉反馈,让交互感觉更加流畅。同时调用selectCell更新选中状态。
构建带动画的单元格
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) => Transform.scale(
scale: _scaleAnimation.value,
child: child,
),
child: Container(
decoration: BoxDecoration(
color: _getCellColor(),
),
child: Center(
child: _buildContent(),
),
),
),
);
}
AnimatedBuilder监听动画值变化,Transform.scale根据动画值缩放单元格。child参数传递不变的内容,避免在动画过程中重复构建。这种优化可以提升动画的流畅度。
长按切换笔记模式
GestureDetector(
onTap: () => controller.selectCell(row, col),
onLongPress: () {
if (!controller.isFixed[row][col]) {
controller.selectCell(row, col);
controller.toggleNotesMode();
HapticFeedback.mediumImpact();
}
},
child: Container(
// 单元格内容
),
)
长按单元格可以快速切换到笔记模式并选中该单元格。这是一个便捷的交互设计,玩家不需要先点击笔记按钮再选择单元格。HapticFeedback提供触觉反馈,让玩家知道操作已被识别。
双击清除功能
GestureDetector(
onTap: () => controller.selectCell(row, col),
onDoubleTap: () {
if (!controller.isFixed[row][col] && controller.board[row][col] != 0) {
controller.selectCell(row, col);
controller.eraseCell();
HapticFeedback.lightImpact();
}
},
child: Container(
// 单元格内容
),
)
双击可以快速清除单元格内容。只有非固定且有内容的单元格才能被清除。这种手势组合让玩家可以更高效地操作棋盘,减少点击次数。
选中状态的阴影效果
Container(
decoration: BoxDecoration(
color: _getCellColor(),
border: Border(
right: BorderSide(
color: (col + 1) % 3 == 0 && col != 8 ? Colors.black : Colors.grey.shade300,
width: (col + 1) % 3 == 0 && col != 8 ? 2 : 1,
),
bottom: BorderSide(
color: (row + 1) % 3 == 0 && row != 8 ? Colors.black : Colors.grey.shade300,
width: (row + 1) % 3 == 0 && row != 8 ? 2 : 1,
),
),
boxShadow: isSelected ? [
BoxShadow(
color: Colors.blue.withOpacity(0.3),
blurRadius: 4,
spreadRadius: 1,
),
] : null,
),
child: Center(
child: _buildContent(),
),
)
选中的单元格添加蓝色阴影效果,让它在视觉上更加突出。阴影使用半透明蓝色,模糊半径4像素,扩散半径1像素。边框在宫格边界处加粗,增强视觉分隔。
键盘导航支持
void _handleKeyEvent(RawKeyEvent event) {
if (event is RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
_moveSelection(-1, 0);
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
_moveSelection(1, 0);
} else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
_moveSelection(0, -1);
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
_moveSelection(0, 1);
}
}
}
方向键可以移动选中位置,这对于使用外接键盘的用户非常有用。RawKeyDownEvent在按键按下时触发,根据不同的方向键调用_moveSelection移动选中位置。
移动选中位置
void _moveSelection(int deltaRow, int deltaCol) {
int newRow = controller.selectedRow + deltaRow;
int newCol = controller.selectedCol + deltaCol;
if (newRow >= 0 && newRow < 9 && newCol >= 0 && newCol < 9) {
controller.selectCell(newRow, newCol);
}
}
_moveSelection方法计算新位置并验证边界,确保不会选中棋盘外的位置。deltaRow和deltaCol表示移动的方向和距离。这种键盘支持提升了应用的可访问性。
无障碍支持
Semantics(
label: '数独单元格,第${row + 1}行第${col + 1}列,${_getCellDescription(row, col)}',
selected: isSelected,
onTap: () => controller.selectCell(row, col),
child: Container(
// 单元格内容
),
)
Semantics组件为屏幕阅读器提供单元格的完整描述,包括位置和内容信息。selected属性标记当前是否选中。这让视障用户也能够使用数独应用。
生成单元格描述
String _getCellDescription(int row, int col) {
int value = controller.board[row][col];
if (value == 0) {
Set<int> notes = controller.notes[row][col];
if (notes.isEmpty) {
return '空';
} else {
return '笔记:${notes.join(', ')}';
}
} else {
bool isFixed = controller.isFixed[row][col];
return '数字$value${isFixed ? ',固定' : ''}';
}
}
_getCellDescription生成单元格的文字描述。空单元格显示"空"或笔记内容,有数字的单元格显示数字和是否固定。这些描述帮助屏幕阅读器用户理解单元格状态。
波纹效果组件
class RippleCell extends StatefulWidget {
final int row;
final int col;
final GameController controller;
const RippleCell({
super.key,
required this.row,
required this.col,
required this.controller,
});
State<RippleCell> createState() => _RippleCellState();
}
RippleCell在点击时显示波纹扩散效果。这种Material Design风格的反馈让交互更加生动。使用StatefulWidget管理波纹动画状态。
波纹动画设置
class _RippleCellState extends State<RippleCell>
with SingleTickerProviderStateMixin {
late AnimationController _rippleController;
late Animation<double> _rippleAnimation;
void initState() {
super.initState();
_rippleController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_rippleAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _rippleController, curve: Curves.easeOut),
);
}
void _onTap() {
_rippleController.forward(from: 0);
widget.controller.selectCell(widget.row, widget.col);
}
波纹动画时长300毫秒,从0到1表示波纹从中心向外扩散的进度。easeOut曲线让波纹开始快结束慢,更自然。点击时从头播放动画。
波纹绘制器
class RipplePainter extends CustomPainter {
final double progress;
final Color color;
RipplePainter({required this.progress, required this.color});
void paint(Canvas canvas, Size size) {
if (progress > 0) {
Paint paint = Paint()
..color = color.withOpacity((1 - progress) * 0.5)
..style = PaintingStyle.fill;
double radius = size.width * progress;
canvas.drawCircle(
Offset(size.width / 2, size.height / 2),
radius,
paint,
);
}
}
bool shouldRepaint(RipplePainter oldDelegate) => progress != oldDelegate.progress;
}
RipplePainter使用CustomPaint绘制波纹。波纹从中心开始,半径随动画进度增大,透明度随进度降低。shouldRepaint确保只在progress变化时重绘,优化性能。
多选模式控制器
class MultiSelectController extends GetxController {
Set<String> selectedCells = {};
bool isMultiSelectMode = false;
void toggleMultiSelectMode() {
isMultiSelectMode = !isMultiSelectMode;
if (!isMultiSelectMode) {
selectedCells.clear();
}
update();
}
void toggleCellSelection(int row, int col) {
String key = '$row,$col';
if (selectedCells.contains(key)) {
selectedCells.remove(key);
} else {
selectedCells.add(key);
}
update();
}
MultiSelectController支持同时选中多个单元格。这在某些高级解题技巧中很有用。selectedCells使用Set存储选中的单元格坐标字符串,避免重复选中。
批量填充选中单元格
bool isCellSelected(int row, int col) {
return selectedCells.contains('$row,$col');
}
void fillSelectedCells(int number, GameController gameController) {
for (String key in selectedCells) {
List<String> parts = key.split(',');
int row = int.parse(parts[0]);
int col = int.parse(parts[1]);
if (!gameController.isFixed[row][col]) {
gameController.board[row][col] = number;
}
}
selectedCells.clear();
isMultiSelectMode = false;
gameController.update();
update();
}
}
fillSelectedCells批量填充所有选中的单元格。解析坐标字符串获取行列,跳过固定单元格。填充完成后清空选中并退出多选模式。
拖动选择组件
class DragSelectBoard extends StatefulWidget {
final GameController controller;
const DragSelectBoard({super.key, required this.controller});
State<DragSelectBoard> createState() => _DragSelectBoardState();
}
class _DragSelectBoardState extends State<DragSelectBoard> {
Set<String> dragSelectedCells = {};
bool isDragging = false;
DragSelectBoard支持拖动选择多个单元格。玩家可以按住并拖动手指,经过的单元格都会被选中。这种交互方式在需要选择一行或一列时特别方便。
处理拖动事件
void _onPanStart(DragStartDetails details) {
isDragging = true;
dragSelectedCells.clear();
_addCellAtPosition(details.localPosition);
}
void _onPanUpdate(DragUpdateDetails details) {
if (isDragging) {
_addCellAtPosition(details.localPosition);
}
}
void _onPanEnd(DragEndDetails details) {
isDragging = false;
}
_onPanStart开始拖动时清空之前的选中,_onPanUpdate拖动过程中持续添加经过的单元格,_onPanEnd结束拖动。这三个回调组成完整的拖动选择流程。
根据位置计算单元格
void _addCellAtPosition(Offset position) {
int row = (position.dy / cellSize).floor();
int col = (position.dx / cellSize).floor();
if (row >= 0 && row < 9 && col >= 0 && col < 9) {
String key = '$row,$col';
if (!dragSelectedCells.contains(key)) {
dragSelectedCells.add(key);
setState(() {});
}
}
}
根据触摸位置计算对应的单元格坐标。position除以cellSize得到行列索引,floor取整。验证边界后添加到选中集合,避免重复添加。
焦点管理器
class CellFocusManager {
int? focusedRow;
int? focusedCol;
void setFocus(int row, int col) {
focusedRow = row;
focusedCol = col;
}
void clearFocus() {
focusedRow = null;
focusedCol = null;
}
CellFocusManager管理单元格的焦点状态。focusedRow和focusedCol使用可空类型,null表示没有焦点。setFocus和clearFocus分别设置和清除焦点。
焦点移动和跳转
void moveFocus(int deltaRow, int deltaCol) {
if (focusedRow == null || focusedCol == null) {
focusedRow = 0;
focusedCol = 0;
return;
}
int newRow = (focusedRow! + deltaRow).clamp(0, 8);
int newCol = (focusedCol! + deltaCol).clamp(0, 8);
focusedRow = newRow;
focusedCol = newCol;
}
void moveToNextEmpty(GameController controller) {
for (int i = 0; i < 81; i++) {
int row = i ~/ 9;
int col = i % 9;
if (controller.board[row][col] == 0 && !controller.isFixed[row][col]) {
focusedRow = row;
focusedCol = col;
return;
}
}
}
}
moveFocus支持方向键导航,clamp确保不会超出棋盘边界。moveToNextEmpty自动跳转到下一个空单元格,方便连续填入数字。遍历81个单元格找到第一个空的非固定单元格。
触觉反馈服务
class HapticFeedbackService {
static void onCellSelect() {
HapticFeedback.selectionClick();
}
static void onNumberInput() {
HapticFeedback.lightImpact();
}
static void onError() {
HapticFeedback.heavyImpact();
}
static void onSuccess() {
HapticFeedback.mediumImpact();
}
static void onNoteToggle() {
HapticFeedback.selectionClick();
}
}
HapticFeedbackService为不同操作提供不同强度的触觉反馈。选中单元格使用轻微的点击反馈,输入错误使用较强的震动提醒。这种多层次的触觉反馈让交互更加丰富。
在交互中使用触觉反馈
void _onCellTap(int row, int col) {
HapticFeedbackService.onCellSelect();
controller.selectCell(row, col);
}
void _onNumberInput(int number) {
if (controller.selectedRow < 0) return;
int row = controller.selectedRow;
int col = controller.selectedCol;
controller.enterNumber(number);
if (controller.hasConflict(row, col)) {
HapticFeedbackService.onError();
} else {
HapticFeedbackService.onNumberInput();
}
}
每次交互都伴随相应的触觉反馈。输入数字后检查是否有冲突,有冲突则使用错误反馈,没有则使用正常反馈。这种即时反馈帮助玩家快速识别错误。
单元格状态指示器
Widget _buildCellIndicator(int row, int col) {
bool isFixed = controller.isFixed[row][col];
bool hasConflict = controller.hasConflict(row, col);
bool isHinted = controller.hintedCells.contains('$row,$col');
List<Widget> indicators = [];
if (hasConflict) {
indicators.add(
Positioned(
top: 2,
right: 2,
child: Container(
width: 6.w,
height: 6.h,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
);
}
单元格角落的小指示器显示额外状态信息。红点表示冲突,使用Positioned定位在右上角。这些指示器不影响主要内容的显示,但提供了有用的辅助信息。
提示指示器
if (isHinted) {
indicators.add(
Positioned(
bottom: 2,
right: 2,
child: Icon(Icons.lightbulb, size: 10.sp, color: Colors.amber),
),
);
}
return Stack(children: indicators);
}
灯泡图标表示这个数字是通过提示填入的。定位在右下角,与冲突指示器不重叠。Stack组合多个指示器,可以同时显示多种状态。
选中历史记录
class SelectionHistory {
List<String> history = [];
int maxHistory = 10;
void recordSelection(int row, int col) {
String key = '$row,$col';
history.remove(key);
history.add(key);
if (history.length > maxHistory) {
history.removeAt(0);
}
}
SelectionHistory记录选中历史,支持返回上一个选中的单元格。先移除已存在的记录再添加,保证最近选中的在最后。maxHistory限制历史记录数量,避免内存占用过多。
返回上一个选中
String? getPreviousSelection() {
if (history.length < 2) return null;
return history[history.length - 2];
}
void goBack(GameController controller) {
String? previous = getPreviousSelection();
if (previous != null) {
List<String> parts = previous.split(',');
controller.selectCell(int.parse(parts[0]), int.parse(parts[1]));
history.removeLast();
}
}
}
getPreviousSelection获取倒数第二个记录(当前选中是最后一个)。goBack解析坐标并选中,然后移除当前记录。这在玩家需要在两个单元格之间来回切换时很有用。
总结
单元格交互与选中的关键设计要点:清晰的视觉反馈(通过颜色区分选中、高亮、冲突等状态)、丰富的手势支持(点击选中、长按笔记、双击清除、拖动多选)、动画效果(选中时的缩放和波纹动画增强交互感)、触觉反馈(不同操作使用不同强度的震动)、键盘导航(支持方向键移动选中位置)、无障碍支持(让所有用户都能使用应用)。
单元格交互是数独游戏的基础,良好的交互设计可以让玩家更专注于解题本身,享受数独带来的乐趣。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐


所有评论(0)