源码已整理 HarmonyCalculator
HarmonyOS 5.0+,基于 MVVM 架构,浅色主题,不规则网格布局,支持加减乘除、连续运算、退格、正负号、百分比

一、设计目标

计算器是移动应用中的经典组件,看似简单却涉及状态管理、表达式解析、连续运算等细节。本文从零实现一款功能完整的鸿蒙计算器,核心需求如下:

  • 基础四则运算(加、减、乘、除)
  • 实时表达式显示(例如输入 7+8 时屏幕显示完整的 7+8,而不是只显示当前数字 8
  • 连续运算7+8-3= 能正确得到 12
  • 退格删除、正负号切换、百分比转换
  • 浅色主题,按钮文字颜色区分(运算符蓝色,数字黑色)

为了实现这些目标,我们采用 MVVM 架构,将 UI 与业务逻辑彻底分离。ViewModel 负责管理所有计算状态和逻辑,View 仅负责渲染和事件分发。

运行效果

计算器.gif

二、核心设计思路

2.1 状态管理

计算器的核心在于准确记录用户的输入序列,并实时更新显示。我们为 ViewModel 设计了以下几个关键状态:

状态变量 类型 作用
displayText string 最终显示在屏幕上的内容(表达式或结果)
currentInput string 当前正在输入的数字(例如用户刚按下 8
previousValue number 上一个参与运算的值(用于连续计算)
operator string 当前待执行的运算符(+-*/
waitingForOperand boolean 是否正在等待下一个操作数(即刚按过运算符)
expression string 累积的表达式字符串(如 7+7+8

其中 expressioncurrentInput 的配合是实现实时表达式显示的关键。

2.2 实时表达式显示原理

普通计算器往往只显示当前输入的数字,用户看不到完整的算式,体验不佳。我们希望做到:

  • 7 → 显示 7
  • + → 显示 7+
  • 8 → 显示 7+8
  • = → 显示 15

设计思路

  • expression 存储已确定的部分(数字+运算符),例如 7+
  • currentInput 存储当前正在输入的数字,例如 8
  • 在非等待操作数状态下,显示内容为 expression + currentInput
  • 当按下运算符后,将 currentInput 和运算符追加到 expression,并清空 currentInput,进入等待操作数状态,此时显示 expression(末尾带运算符)。
  • 当按下数字时,如果处于等待操作数状态,则开始新的 currentInput,退出等待状态,显示重新变为 expression + currentInput

这样就能平滑地实现表达式的逐字构建。

2.3 连续运算的支持

连续运算是指用户输入 7+8-3= 时,计算器能正确得到 12,而不是只计算最后一步。实现策略:

  • 当用户按下运算符(例如 -)且已有运算符未等待操作数时,说明用户想要连续计算(例如 7+8 后按 -)。此时先计算前一步的结果(7+8=15),将结果显示并作为新的 previousValue,然后将新运算符(-)保存,进入等待操作数状态。
  • 这样无论用户连续输入多少个运算符,都能保证每一步及时计算,最终等号只需计算最后一步。

三、关键代码实现与文字说明

3.1 数字输入

onDigit(digit: string) {
  if (this.waitingForOperand) {
    // 刚按过运算符,开始新数字
    this.currentInput = digit;
    this.waitingForOperand = false;
  } else {
    // 正常追加数字,处理前导零
    if (this.currentInput === '0' && digit !== '.') {
      this.currentInput = digit;
    } else {
      this.currentInput += digit;
    }
  }
  this.updateDisplay();
}

逻辑说明

  • 如果处于等待操作数状态(即上次输入是运算符),则当前输入的数字是新操作数的开始,直接赋值给 currentInput,并退出等待状态。
  • 否则,将数字追加到 currentInput 末尾,但需避免多个前导零(例如 00 不合法)。
  • 每次修改后调用 updateDisplay() 刷新屏幕。

3.2 运算符处理(含连续运算)

onOperator(op: string) {
  const currentVal = parseFloat(this.currentInput);
  // 连续运算分支:已有运算符且未等待操作数
  if (this.operator && !this.waitingForOperand) {
    this.previousValue = this.calculate(this.previousValue, currentVal, this.operator);
    this.displayText = this.previousValue.toString();
    this.currentInput = this.displayText;
    // 表达式更新为结果 + 新运算符
    this.expression = this.displayText + this.getDisplayOperator(op);
    this.operator = op;
    this.waitingForOperand = true;
    this.updateDisplay();
    return;
  }
  // 正常情况:将当前数字和运算符追加到表达式
  if (this.expression === '') {
    this.expression = this.currentInput + this.getDisplayOperator(op);
  } else {
    this.expression += this.currentInput + this.getDisplayOperator(op);
  }
  this.operator = op;
  this.previousValue = currentVal;
  this.waitingForOperand = true;
  this.updateDisplay();
}

逻辑说明

  • 首先将当前输入的字符串转为数字 currentVal
  • 连续运算判断:如果 operator 非空且不是等待操作数状态(说明用户已经输入了第二个操作数),则先计算前一步结果,将结果设为新的 currentInput,并用结果和新运算符重建 expression
  • 正常情况(首次按下运算符,或连续运算完成后的新运算符):将 currentInput 和运算符追加到 expression 中,保存运算符和 previousValue,进入等待操作数状态。
  • 注意使用 getDisplayOperator() 将内部运算符(*/)转换为显示用的 ×÷

3.3 等号计算

onEqual() {
  if (!this.operator || this.waitingForOperand) return;
  this.expression += this.currentInput;            // 将最后一个数字补全
  const result = this.calculate(this.previousValue, parseFloat(this.currentInput), this.operator);
  this.displayText = result.toString();
  // 清空状态,但将结果作为下次运算的起点
  this.currentInput = this.displayText;
  this.previousValue = 0;
  this.operator = '';
  this.waitingForOperand = true;
  this.expression = '';
}

逻辑说明

  • 只有存在运算符且不在等待操作数状态时才能计算。
  • 先将最后一个数字追加到表达式(用于显示完整算式,虽然等号后我们会清空它)。
  • 计算结果,更新显示。
  • 清空表达式和运算符,但将结果存入 currentInput,并将 waitingForOperand 设为 true。这样做的好处是:用户按下等号后,如果接着按下运算符,可以直接对结果进行下一步运算,符合使用习惯。

3.4 显示更新逻辑

private updateDisplay() {
  if (this.waitingForOperand && this.expression !== '') {
    // 刚按过运算符,显示表达式(末尾已带运算符)
    this.displayText = this.expression;
  } else {
    if (this.expression === '') {
      this.displayText = this.currentInput === '' ? '0' : this.currentInput;
    } else {
      // 显示表达式 + 当前输入的数字
      this.displayText = this.expression + this.currentInput;
    }
  }
}

逻辑说明

  • 等待操作数状态:此时 currentInput 是上一个数字(已被移入表达式),我们只显示 expression(例如 7+)。
  • 非等待状态:如果表达式为空,直接显示当前输入数字(或 0);否则将表达式和当前数字拼接显示(例如 7+8)。
  • 这样保证了用户始终看到完整的输入过程。

3.5 辅助功能

退格

onDelete() {
  if (this.waitingForOperand) return;
  if (this.currentInput.length > 0) {
    this.currentInput = this.currentInput.slice(0, -1);
    if (this.currentInput === '' || this.currentInput === '-') {
      this.currentInput = '0';
    }
    this.updateDisplay();
  }
}

仅当不在等待操作数状态时允许删除当前输入的最后一位,删除后若为空或仅剩负号则重置为 0

正负号

onSign() {
  if (this.currentInput === '') return;
  let val = parseFloat(this.currentInput);
  val = -val;
  this.currentInput = val.toString();
  this.updateDisplay();
}

百分比

onPercent() {
  if (this.currentInput === '') return;
  let val = parseFloat(this.currentInput);
  val = val / 100;
  this.currentInput = val.toString();
  this.updateDisplay();
}

清除
重置所有状态变量,displayText 置为 '0'

四、完整 ViewModel 代码

export class CalculatorViewModel {
  // 显示内容:表达式或结果
  displayText: string = '0';
  // 当前正在输入的数字(字符串形式)
  private currentInput: string = '';
  // 上一个参与运算的值(用于连续计算)
  private previousValue: number = 0;
  // 当前待执行的运算符(+、-、*、/)
  private operator: string = '';
  // 是否正在等待下一个操作数(即刚按过运算符)
  private waitingForOperand: boolean = false;
  // 累积的表达式字符串(例如 "7+" 或 "7+8")
  private expression: string = '';

  // 处理数字键输入
  onDigit(digit: string) {
    if (this.waitingForOperand) {
      // 正在等待操作数 -> 开始新数字
      this.currentInput = digit;
      this.waitingForOperand = false;
    } else {
      // 正常追加数字,避免前导零(如 "00")
      if (this.currentInput === '0' && digit !== '.') {
        this.currentInput = digit;
      } else {
        this.currentInput += digit;
      }
    }
    this.updateDisplay();
  }

  // 处理小数点输入
  onDot() {
    if (this.waitingForOperand) {
      // 等待操作数时,直接开始 "0."
      this.currentInput = '0.';
      this.waitingForOperand = false;
    } else if (this.currentInput.indexOf('.') === -1) {
      // 当前数字没有小数点时,才追加小数点
      if (this.currentInput === '') {
        this.currentInput = '0.';
      } else {
        this.currentInput += '.';
      }
    }
    this.updateDisplay();
  }

  // 处理运算符输入(+、-、×、÷)
  onOperator(op: string) {
    const currentVal = parseFloat(this.currentInput);
    // 连续运算:已有运算符且未等待操作数 -> 先计算前一步
    if (this.operator && !this.waitingForOperand) {
      this.previousValue = this.calculate(this.previousValue, currentVal, this.operator);
      this.displayText = this.previousValue.toString();
      this.currentInput = this.displayText;
      // 用计算结果和新运算符重建表达式
      this.expression = this.displayText + this.getDisplayOperator(op);
      this.operator = op;
      this.waitingForOperand = true;
      this.updateDisplay();
      return;
    }
    // 正常情况:将当前数字和运算符追加到表达式
    if (this.expression === '') {
      this.expression = this.currentInput + this.getDisplayOperator(op);
    } else {
      this.expression += this.currentInput + this.getDisplayOperator(op);
    }
    this.operator = op;
    this.previousValue = currentVal;
    this.waitingForOperand = true;
    this.updateDisplay();
  }

  // 处理等号:计算结果并更新状态
  onEqual() {
    if (!this.operator || this.waitingForOperand) return;
    // 将最后一个数字补全到表达式(仅用于显示,计算后清空)
    this.expression += this.currentInput;
    const currentVal = parseFloat(this.currentInput);
    const result = this.calculate(this.previousValue, currentVal, this.operator);
    this.displayText = result.toString();
    // 清空运算状态,但保留结果作为下次运算的起点
    this.currentInput = this.displayText;
    this.previousValue = 0;
    this.operator = '';
    this.waitingForOperand = true;
    this.expression = '';
  }

  // 清除所有状态,显示归零
  onClear() {
    this.currentInput = '';
    this.previousValue = 0;
    this.operator = '';
    this.waitingForOperand = false;
    this.expression = '';
    this.displayText = '0';
  }

  // 切换当前数字的正负号
  onSign() {
    if (this.currentInput === '') return;
    let val = parseFloat(this.currentInput);
    val = -val;
    this.currentInput = val.toString();
    this.updateDisplay();
  }

  // 将当前数字转换为百分比(除以100)
  onPercent() {
    if (this.currentInput === '') return;
    let val = parseFloat(this.currentInput);
    val = val / 100;
    this.currentInput = val.toString();
    this.updateDisplay();
  }

  // 退格:删除当前输入的最后一位字符
  onDelete() {
    if (this.waitingForOperand) return; // 等待操作数时,退格无效
    if (this.currentInput.length > 0) {
      this.currentInput = this.currentInput.slice(0, -1);
      // 删除后若为空或仅剩负号,则重置为0
      if (this.currentInput === '' || this.currentInput === '-') {
        this.currentInput = '0';
      }
      this.updateDisplay();
    }
  }

  // 将内部运算符转换为显示用的符号(*→×,/→÷)
  private getDisplayOperator(op: string): string {
    switch (op) {
      case '*': return '×';
      case '/': return '÷';
      default: return op;
    }
  }

  // 更新屏幕显示:根据当前状态组合表达式和当前输入
  private updateDisplay() {
    if (this.waitingForOperand && this.expression !== '') {
      // 刚按过运算符,显示表达式(末尾已有运算符)
      this.displayText = this.expression;
    } else {
      if (this.expression === '') {
        // 无表达式时,显示当前输入(若无则显示0)
        this.displayText = this.currentInput === '' ? '0' : this.currentInput;
      } else {
        // 显示完整表达式 + 当前输入
        this.displayText = this.expression + this.currentInput;
      }
    }
  }

  // 执行实际的计算
  private calculate(a: number, b: number, op: string): number {
    switch (op) {
      case '+': return a + b;
      case '-': return a - b;
      case '*': return a * b;
      case '/': return b !== 0 ? a / b : 0;
      default: return b;
    }
  }
}

四、布局页面

import { ButtonItem } from '../model/ButtonItem';
import { CalculatorViewModel } from '../viewmodel/CalculatorViewModel';

@Entry
@Component
struct CalculatorPage {
  @State viewModel: CalculatorViewModel = new CalculatorViewModel();
  private scroller: Scroller = new Scroller();

  private buttons: ButtonItem[] = [
    // 第一行:功能键和运算符
    { label: 'C', fontColor: '#007AFF', action: () => { this.viewModel.onClear(); } },
    { label: '÷', fontColor: '#007AFF', action: () => { this.viewModel.onOperator('/'); } },
    { label: '×', fontColor: '#007AFF', action: () => { this.viewModel.onOperator('*'); } },
    { label: '⌫', fontColor: '#007AFF', action: () => { this.viewModel.onDelete(); } },
    // 第二行:7 8 9 减号
    { label: '7', fontColor: '#000000', action: () => { this.viewModel.onDigit('7'); } },
    { label: '8', fontColor: '#000000', action: () => { this.viewModel.onDigit('8'); } },
    { label: '9', fontColor: '#000000', action: () => { this.viewModel.onDigit('9'); } },
    { label: '-', fontColor: '#007AFF', action: () => { this.viewModel.onOperator('-'); } },
    // 第三行:4 5 6 加号
    { label: '4', fontColor: '#000000', action: () => { this.viewModel.onDigit('4'); } },
    { label: '5', fontColor: '#000000', action: () => { this.viewModel.onDigit('5'); } },
    { label: '6', fontColor: '#000000', action: () => { this.viewModel.onDigit('6'); } },
    { label: '+', fontColor: '#007AFF', action: () => { this.viewModel.onOperator('+'); } },
    // 第四行:1 2 3 等号
    { label: '1', fontColor: '#000000', action: () => { this.viewModel.onDigit('1'); } },
    { label: '2', fontColor: '#000000', action: () => { this.viewModel.onDigit('2'); } },
    { label: '3', fontColor: '#000000', action: () => { this.viewModel.onDigit('3'); } },
    { label: '=', fontColor: '#007AFF', action: () => { this.viewModel.onEqual(); } },
    // 第五行:百分比 0 小数点
    { label: '%', fontColor: '#007AFF', action: () => { this.viewModel.onPercent(); } },
    { label: '0', fontColor: '#000000', action: () => { this.viewModel.onDigit('0'); } },
    { label: '.', fontColor: '#000000', action: () => { this.viewModel.onDot(); } }
  ];

  // 不规则布局配置:使 "=" 按钮跨两行
   private layoutOptions: GridLayoutOptions = {
    regularSize: [1, 1],
    onGetRectByIndex: (index: number) => {
      if (index === 15) {
        return [3, 3, 2, 1];
      }else{
        const row = Math.floor(index / 4);
        const col = index % 4;
        return [row, col, 1, 1];
      }
    }
  };

  build() {
    Column() {
      Text(this.viewModel.displayText)
        .width('100%')
        .fontSize(56)
        .fontColor(Color.Black)
        .textAlign(TextAlign.End)
        .backgroundColor('#F2F2F2')
        .padding(16)
        .margin({ bottom: 10 })
        .layoutWeight(1)

      // 按钮网格:使用不规则布局
      Grid(this.scroller, this.layoutOptions) {
        ForEach(this.buttons, (item: ButtonItem, index: number) => {
          GridItem() {
            this.buttonItem(item)
          }
        }, (item: ButtonItem, index: number) => index.toString()) 
      }
      .columnsTemplate('1fr 1fr 1fr 1fr')
      .rowsTemplate('1fr 1fr 1fr 1fr 1fr')   // 5行,适应等号跨行
      .columnsGap(10)
      .rowsGap(10)
      .padding(10)
      .width('100%')
      .height('60%')
      .backgroundColor(Color.White)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F2F2F2')
  }

  // 单个按钮构建器
  @Builder
  buttonItem(item: ButtonItem) {
    Button(item.label, {
      buttonStyle: ButtonStyleMode.NORMAL,
      type: item.label === '=' ? ButtonType.Capsule : ButtonType.Circle
    })
      .width('100%')
      .height('100%')
      .fontSize(28)
      .fontColor(item.fontColor)
      .backgroundColor(Color.White)              
      .borderRadius(40)
      .border({ width: 1, color: '#E0E0E0' })    
      .shadow({ radius: 2, color: '#00000020', offsetX: 0, offsetY: 1 }) 
      .onClick(() => {
        if (item.action) item.action();
      })
  }
}

五、总结

基础计算器功能很简单但是我们的重点是通过 ViewModel 中表达式构建和连续运算的核心设计思路,并辅以关键代码片段。可以根据此思路快速实现一个功能完整的计算器,并轻松扩展科学计算等高级功能。如果觉得有用,请点赞、收藏、转发支持!谢谢

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐