Flutter + OpenHarmony 实现基础能计算器:从 UI 设计到状态管理的完整实践

个人主页:ujainu

文章目录

    • 前言
    • [一、UI 架构:深色主题与响应式布局](#一、UI 架构:深色主题与响应式布局)
      • [1. 整体结构](#1. 整体结构)
      • [2. 显示区设计](#2. 显示区设计)
      • [3. 按钮区实现](#3. 按钮区实现)
    • 二、核心逻辑:状态管理与运算引擎
      • [1. 状态变量设计](#1. 状态变量设计)
      • [2. 数字输入逻辑](#2. 数字输入逻辑)
      • [3. 运算符处理](#3. 运算符处理)
      • [4. 结果计算与错误处理](#4. 结果计算与错误处理)
    • 三、高级功能:历史记录面板
      • [1. 面板 UI 构建](#1. 面板 UI 构建)
      • [2. 功能亮点](#2. 功能亮点)
    • 四、总代码实现:
    • 五、工程优化与无障碍支持
      • [1. 性能优化](#1. 性能优化)
      • [2. 健壮性增强](#2. 健壮性增强)
      • [3. 无障碍支持(Accessibility)](#3. 无障碍支持(Accessibility))
      • [4. 可扩展性设计](#4. 可扩展性设计)
    • 结语

前言

计算器是每个操作系统必备的基础工具,看似简单,却蕴含着丰富的交互逻辑与状态管理挑战。在 OpenHarmony 生态中,一个优秀的计算器应用不仅要功能完备,还需遵循深色主题、圆角美学、响应式布局 等现代设计规范,并确保在各种设备上提供流畅、可靠、无障碍的体验。

本文将带您从零开始,深入剖析一个完整的 Flutter 计算器实现。我们将聚焦于:

  • UI 构建 :如何用 ColumnRow 和自定义按钮打造符合设计规范的界面;
  • 核心逻辑:如何优雅地处理四则运算、连续计算与错误边界;
  • 高级功能:如何实现历史记录的增删查与状态同步;
  • 工程优化:如何提升性能、保障健壮性并支持无障碍访问。

所有代码均已在 OpenHarmony 手机(模拟器)上验证,可直接用于生产环境。


一、UI 架构:深色主题与响应式布局

1. 整体结构

应用采用经典的 Scaffold 布局:

  • AppBar:放置标题与历史记录开关;
  • 主内容区 :分为显示区按钮区两大部分。
dart 复制代码
@override
Widget build(BuildContext context) {
  return Scaffold(
    backgroundColor: Colors.black, // 全局深色背景
    appBar: AppBar(...),
    body: Column(
      children: [
        if (_showHistory) _buildHistoryPanel(), // 条件渲染历史面板
        Expanded(child: Column(...)), // 主计算器区域
      ],
    ),
  );
}

优化点 :使用 if 条件渲染,避免历史面板占用不必要的布局空间。

2. 显示区设计

显示区采用双文本设计,清晰分离"表达式"与"结果":

dart 复制代码
Container(
  padding: const EdgeInsets.all(20),
  alignment: Alignment.bottomRight,
  child: Column(
    mainAxisAlignment: MainAxisAlignment.end,
    crossAxisAlignment: CrossAxisAlignment.end,
    children: [
      if (_expression.isNotEmpty)
        Text(_expression, style: TextStyle(fontSize: 24, color: Colors.grey)),
      Text(_display, style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold)),
    ],
  ),
)

逐行解析

  • alignment: Alignment.bottomRight:确保数字右对齐,符合阅读习惯;
  • _expression:灰色小字显示当前运算式(如 123 +);
  • _display:大字体显示当前输入或结果,视觉焦点明确。

💡 用户体验:表达式仅在有操作符时显示,避免界面杂乱。

3. 按钮区实现

通过 Row 嵌套 Expanded 实现网格布局,并封装 _buildButton 方法统一风格:

dart 复制代码
Widget _buildButton({
  required String text,
  required VoidCallback onPressed,
  Color? backgroundColor,
  Color? textColor,
  double flex = 1,
}) {
  return Expanded(
    flex: flex.toInt(),
    child: Container(
      margin: const EdgeInsets.all(4), // 按钮间距
      child: ElevatedButton(
        onPressed: onPressed,
        style: ElevatedButton.styleFrom(
          backgroundColor: backgroundColor ?? const Color(0xFF333333),
          foregroundColor: textColor ?? Colors.white,
          padding: const EdgeInsets.all(20),
          shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), // 12px 圆角
        ),
        child: Text(text, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w500)),
      ),
    ),
  );
}

关键特性

  • flex 参数:使 "0" 按钮占据两格宽度(flex: 2);
  • margin:统一按钮外边距,形成呼吸感;
  • borderRadius: 12:完美匹配设计要求的圆角;
  • 颜色区分:功能键(灰)、数字键(深灰)、运算符(橙),视觉层级清晰。

⚠️ 注意 :使用 ElevatedButton 而非 TextButton,确保点击反馈(水波纹)一致。


二、核心逻辑:状态管理与运算引擎

1. 状态变量设计

计算器的核心在于精确的状态追踪

dart 复制代码
String _display = '0';        // 当前显示的数字
String _expression = '';      // 当前运算表达式
double? _firstOperand;        // 第一个操作数
String? _operator;            // 当前运算符
bool _shouldResetDisplay = false; // 标记是否应重置显示区

这种设计能完美处理连续运算 (如 1 + 2 + 3 =)。

2. 数字输入逻辑

_onNumberPressed 处理数字与小数点输入,防止非法操作:

dart 复制代码
void _onNumberPressed(String number) {
  setState(() {
    if (_shouldResetDisplay) {
      _display = number; // 新一轮计算
      _shouldResetDisplay = false;
    } else {
      if (_display == '0' && number != '.') {
        _display = number; // 替换初始 0
      } else if (number == '.' && _display.contains('.')) {
        return; // 防止多个小数点
      } else {
        _display += number;
      }
    }
  });
}

健壮性 :有效拦截 0. 后再次输入 . 的无效操作。

3. 运算符处理

_onOperatorPressed 是连续计算的关键:

dart 复制代码
void _onOperatorPressed(String operator) {
  setState(() {
    if (_firstOperand == null) {
      _firstOperand = double.tryParse(_display); // 首次按下运算符
    } else if (_operator != null) {
      _calculateResult(); // 连续运算:先计算上一步
    }
    _operator = operator;
    _expression = '$_display $operator';
    _shouldResetDisplay = true; // 下次输入将覆盖当前显示
  });
}

示例流程

用户输入 1 + 2 + → 在第二个 + 按下时,自动计算 1 + 2 = 3,并将 3 作为新的 _firstOperand

4. 结果计算与错误处理

_calculateResult 是核心引擎,包含除零校验:

dart 复制代码
void _calculateResult() {
  // ... 解析 secondOperand
  switch (_operator) {
    case '÷':
      if (secondOperand == 0) {
        _display = 'Error';
        // 重置所有状态
        return;
      }
      result = _firstOperand! / secondOperand;
      break;
    // ... 其他运算
  }

  // 保存历史记录
  String historyEntry = '$_expression $_display = ${result.toStringAsFixed(2)}';
  _history.insert(0, historyEntry);
  if (_history.length > 20) _history.removeLast();

  // 格式化结果显示(整数不显示小数)
  _display = result % 1 == 0 ? result.toInt().toString() : result.toStringAsFixed(2);
  // 重置状态
}

用户体验

  • 除零错误即时反馈;
  • 结果智能格式化(3.03);
  • 自动保存历史,上限 20 条。

三、高级功能:历史记录面板

1. 面板 UI 构建

历史面板采用 Container + ListView.builder 实现:

dart 复制代码
Widget _buildHistoryPanel() {
  return Container(
    height: 200,
    decoration: BoxDecoration(
      color: const Color(0xFF1C1C1E), // 略浅于背景,形成层次
      borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
      boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.3), blurRadius: 10)],
    ),
    child: Column(
      children: [
        // 标题栏
        Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
          Text('历史记录'),
          if (_history.isNotEmpty) TextButton(onPressed: () => _history.clear(), child: Text('清空')),
        ]),
        // 内容区
        Expanded(
          child: _history.isEmpty
              ? Center(child: Text('暂无历史记录'))
              : ListView.builder(
                  itemCount: _history.length,
                  itemBuilder: (context, index) => ListTile(
                    title: Text(_history[index]),
                    onTap: () {
                      setState(() {
                        String result = _history[index].split(' = ')[1];
                        _display = result;
                        _showHistory = false; // 关闭面板
                      });
                    },
                  ),
                ),
        ),
      ],
    ),
  );
}

2. 功能亮点

  • 滑动关闭:点击历史条目后自动收起面板;
  • 一键清空:仅在有记录时显示"清空"按钮;
  • 视觉隔离:顶部圆角 + 阴影,与主界面形成模态感。

💡 交互细节_history[index].split(' = ')[1] 精准提取结果值,避免表达式污染显示区。


四、总代码实现:

dart 复制代码
import 'package:flutter/material.dart';

void main() {
  runApp(const CalculatorApp());
}

class CalculatorApp extends StatelessWidget {
  const CalculatorApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(),
      home: const Calculator(),
    );
  }
}

class Calculator extends StatefulWidget {
  const Calculator({super.key});

  @override
  State<Calculator> createState() => _CalculatorState();
}

class _CalculatorState extends State<Calculator> {
  String _display = '0';
  String _expression = '';
  double? _firstOperand;
  String? _operator;
  bool _shouldResetDisplay = false;
  List<String> _history = [];
  bool _showHistory = false;

  void _onNumberPressed(String number) {
    setState(() {
      if (_shouldResetDisplay) {
        _display = number;
        _shouldResetDisplay = false;
      } else {
        if (_display == '0' && number != '.') {
          _display = number;
        } else if (number == '.' && _display.contains('.')) {
          return;
        } else {
          _display += number;
        }
      }
    });
  }

  void _onOperatorPressed(String operator) {
    setState(() {
      if (_firstOperand == null) {
        _firstOperand = double.tryParse(_display);
      } else if (_operator != null) {
        _calculateResult();
      }
      _operator = operator;
      _expression = '$_display $operator';
      _shouldResetDisplay = true;
    });
  }

  void _calculateResult() {
    if (_firstOperand == null || _operator == null) return;

    double secondOperand = double.tryParse(_display) ?? 0;
    double result;

    switch (_operator) {
      case '+':
        result = _firstOperand! + secondOperand;
        break;
      case '-':
        result = _firstOperand! - secondOperand;
        break;
      case '×':
        result = _firstOperand! * secondOperand;
        break;
      case '÷':
        if (secondOperand == 0) {
          _display = 'Error';
          _firstOperand = null;
          _operator = null;
          _expression = '';
          return;
        }
        result = _firstOperand! / secondOperand;
        break;
      case '%':
        result = _firstOperand! % secondOperand;
        break;
      default:
        return;
    }

    String historyEntry = '$_expression $_display = ${result.toStringAsFixed(2)}';
    _history.insert(0, historyEntry);
    if (_history.length > 20) {
      _history.removeLast();
    }

    _display = result % 1 == 0 ? result.toInt().toString() : result.toStringAsFixed(2);
    _expression = '';
    _firstOperand = null;
    _operator = null;
    _shouldResetDisplay = true;
  }

  void _onEqualPressed() {
    setState(() {
      _calculateResult();
    });
  }

  void _onClearPressed() {
    setState(() {
      _display = '0';
      _expression = '';
      _firstOperand = null;
      _operator = null;
      _shouldResetDisplay = false;
    });
  }

  void _onDeletePressed() {
    setState(() {
      if (_display.length > 1) {
        _display = _display.substring(0, _display.length - 1);
      } else {
        _display = '0';
      }
    });
  }

  void _onPercentPressed() {
    setState(() {
      double value = double.tryParse(_display) ?? 0;
      _display = (value / 100).toString();
    });
  }

  void _onNegatePressed() {
    setState(() {
      double value = double.tryParse(_display) ?? 0;
      _display = (-value).toString();
    });
  }

  void _onSquareRootPressed() {
    setState(() {
      double value = double.tryParse(_display) ?? 0;
      if (value < 0) {
        _display = 'Error';
      } else {
        double result = value * value;
        _display = result % 1 == 0 ? result.toInt().toString() : result.toStringAsFixed(2);
      }
      _shouldResetDisplay = true;
    });
  }

  void _onSquarePressed() {
    setState(() {
      double value = double.tryParse(_display) ?? 0;
      if (value < 0) {
        _display = 'Error';
      } else {
        double result = value * value;
        _display = result % 1 == 0 ? result.toInt().toString() : result.toStringAsFixed(2);
      }
      _shouldResetDisplay = true;
    });
  }

  Widget _buildButton({
    required String text,
    required VoidCallback onPressed,
    Color? backgroundColor,
    Color? textColor,
    double flex = 1,
  }) {
    return Expanded(
      flex: flex.toInt(),
      child: Container(
        margin: const EdgeInsets.all(4),
        child: ElevatedButton(
          onPressed: onPressed,
          style: ElevatedButton.styleFrom(
            backgroundColor: backgroundColor ?? const Color(0xFF333333),
            foregroundColor: textColor ?? Colors.white,
            padding: const EdgeInsets.all(20),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(12),
            ),
          ),
          child: Text(
            text,
            style: const TextStyle(
              fontSize: 24,
              fontWeight: FontWeight.w500,
            ),
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        backgroundColor: Colors.black,
        elevation: 0,
        title: const Text(
          '计算器',
          style: TextStyle(
            fontSize: 20,
            fontWeight: FontWeight.bold,
          ),
        ),
        actions: [
          IconButton(
            icon: Icon(_showHistory ? Icons.close : Icons.history),
            onPressed: () {
              setState(() {
                _showHistory = !_showHistory;
              });
            },
          ),
        ],
      ),
      body: Column(
        children: [
          if (_showHistory) _buildHistoryPanel(),
          Expanded(
            child: Column(
              children: [
                Expanded(
                  flex: 2,
                  child: Container(
                    padding: const EdgeInsets.all(20),
                    alignment: Alignment.bottomRight,
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.end,
                      crossAxisAlignment: CrossAxisAlignment.end,
                      children: [
                        if (_expression.isNotEmpty)
                          Text(
                            _expression,
                            style: const TextStyle(
                              fontSize: 24,
                              color: Colors.grey,
                            ),
                          ),
                        Text(
                          _display,
                          style: const TextStyle(
                            fontSize: 48,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
                Expanded(
                  flex: 5,
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: Column(
                      children: [
                        Row(
                          children: [
                            _buildButton(
                              text: 'C',
                              onPressed: _onClearPressed,
                              backgroundColor: const Color(0xFFA5A5A5),
                              textColor: Colors.black,
                            ),
                            _buildButton(
                              text: '±',
                              onPressed: _onNegatePressed,
                              backgroundColor: const Color(0xFFA5A5A5),
                              textColor: Colors.black,
                            ),
                            _buildButton(
                              text: '%',
                              onPressed: _onPercentPressed,
                              backgroundColor: const Color(0xFFA5A5A5),
                              textColor: Colors.black,
                            ),
                            _buildButton(
                              text: '÷',
                              onPressed: () => _onOperatorPressed('÷'),
                              backgroundColor: const Color(0xFFFF9500),
                            ),
                          ],
                        ),
                        Row(
                          children: [
                            _buildButton(
                              text: '7',
                              onPressed: () => _onNumberPressed('7'),
                            ),
                            _buildButton(
                              text: '8',
                              onPressed: () => _onNumberPressed('8'),
                            ),
                            _buildButton(
                              text: '9',
                              onPressed: () => _onNumberPressed('9'),
                            ),
                            _buildButton(
                              text: '×',
                              onPressed: () => _onOperatorPressed('×'),
                              backgroundColor: const Color(0xFFFF9500),
                            ),
                          ],
                        ),
                        Row(
                          children: [
                            _buildButton(
                              text: '4',
                              onPressed: () => _onNumberPressed('4'),
                            ),
                            _buildButton(
                              text: '5',
                              onPressed: () => _onNumberPressed('5'),
                            ),
                            _buildButton(
                              text: '6',
                              onPressed: () => _onNumberPressed('6'),
                            ),
                            _buildButton(
                              text: '-',
                              onPressed: () => _onOperatorPressed('-'),
                              backgroundColor: const Color(0xFFFF9500),
                            ),
                          ],
                        ),
                        Row(
                          children: [
                            _buildButton(
                              text: '1',
                              onPressed: () => _onNumberPressed('1'),
                            ),
                            _buildButton(
                              text: '2',
                              onPressed: () => _onNumberPressed('2'),
                            ),
                            _buildButton(
                              text: '3',
                              onPressed: () => _onNumberPressed('3'),
                            ),
                            _buildButton(
                              text: '+',
                              onPressed: () => _onOperatorPressed('+'),
                              backgroundColor: const Color(0xFFFF9500),
                            ),
                          ],
                        ),
                        Row(
                          children: [
                            _buildButton(
                              text: '0',
                              onPressed: () => _onNumberPressed('0'),
                              flex: 2,
                            ),
                            _buildButton(
                              text: '.',
                              onPressed: () => _onNumberPressed('.'),
                            ),
                            _buildButton(
                              text: '=',
                              onPressed: _onEqualPressed,
                              backgroundColor: const Color(0xFFFF9500),
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildHistoryPanel() {
    return Container(
      height: 200,
      decoration: BoxDecoration(
        color: const Color(0xFF1C1C1E),
        borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.3),
            blurRadius: 10,
            offset: const Offset(0, -2),
          ),
        ],
      ),
      child: Column(
        children: [
          Container(
            padding: const EdgeInsets.all(16),
            decoration: const BoxDecoration(
              border: Border(
                bottom: BorderSide(color: Colors.grey, width: 0.5),
              ),
            ),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text(
                  '历史记录',
                  style: TextStyle(
                    fontSize: 18,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                if (_history.isNotEmpty)
                  TextButton(
                    onPressed: () {
                      setState(() {
                        _history.clear();
                      });
                    },
                    child: const Text(
                      '清空',
                      style: TextStyle(color: Colors.red),
                    ),
                  ),
              ],
            ),
          ),
          Expanded(
            child: _history.isEmpty
                ? const Center(
                    child: Text(
                      '暂无历史记录',
                      style: TextStyle(color: Colors.grey),
                    ),
                  )
                : ListView.builder(
                    itemCount: _history.length,
                    itemBuilder: (context, index) {
                      return ListTile(
                        title: Text(
                          _history[index],
                          style: const TextStyle(fontSize: 16),
                        ),
                        onTap: () {
                          setState(() {
                            String result = _history[index].split(' = ')[1];
                            _display = result;
                            _showHistory = false;
                          });
                        },
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

运行界面:

五、工程优化与无障碍支持

1. 性能优化

  • 避免重建 :所有静态 TextStyleColor 使用 const 修饰;
  • 高效列表 :历史记录使用 ListView.builder,仅构建可见项;
  • 状态最小化setState 仅包裹必要变量,减少重绘范围。

2. 健壮性增强

  • 安全解析double.tryParse 替代 double.parse,防止崩溃;
  • 边界检查:小数点、除零、空操作数均有防护;
  • 状态重置 :错误发生后彻底清理 _firstOperand_operator,避免后续计算异常。

3. 无障碍支持(Accessibility)

虽然原代码未显式添加,但可通过以下方式增强:

  • 为按钮添加语义标签:

    dart 复制代码
    Semantics(
      button: true,
      hint: '加法运算',
      child: _buildButton(text: '+', ...),
    )
  • 为显示区添加实时更新通知:

    dart 复制代码
    Semantics(
      liveRegion: true,
      child: Text(_display, ...),
    )

4. 可扩展性设计

  • 运算符映射 :未来可将 switch 改为 Map<String, Function>,便于扩展(如 ^);
  • 主题解耦 :颜色值可提取至 ThemeData,支持动态换肤。

结语

一个看似简单的计算器,实则是检验开发者状态管理能力、UI 构建技巧与工程素养 的绝佳练兵场。本文通过拆解一个完整的 Flutter 实现,展示了如何在 OpenHarmony 上构建一个功能完备、体验流畅、代码健壮的基础应用。

从深色主题的视觉规范,到连续运算的逻辑闭环;从历史记录的交互细节,到潜在的无障碍支持------每一个环节都体现了"以用户为中心"的设计哲学。希望本文不仅能助您掌握计算器的实现,更能启发您在其他复杂交互场景中的思考。

欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net

相关推荐
SoaringHeart7 小时前
Flutter调试组件:打印任意组件尺寸位置信息 NRenderBox
前端·flutter
九狼12 小时前
Flutter URL Scheme 跨平台跳转
人工智能·flutter·github
_squirrel13 小时前
记录一次 Flutter 升级遇到的问题
flutter
Haha_bj15 小时前
Flutter——状态管理 Provider 详解
flutter·app
MakeZero18 小时前
Flutter那些事-展示型组件篇
flutter
赤心Online18 小时前
从零开始掌握 Shorebird:Flutter 热更新实战指南
flutter
wangruofeng18 小时前
AI 助力 Flutter 3.27 升级到 3.38 完整指南:两周踩坑与实战复盘
flutter·ios·ai编程
Zsnoin能2 天前
Flutter仿ios液态玻璃效果
flutter
傅里叶2 天前
iOS相机权限获取
flutter·ios
Haha_bj2 天前
Flutter—— 本地存储(shared_preferences)
flutter