鸿蒙多功能工具箱开发实战(十)-计算器模块界面设计与交互优化

前言

良好的界面设计和交互体验是提升用户满意度的关键。在HarmonyOS应用开发中,计算器类功能是常见的需求,如养老金计算器、日期计算器、BMI计算器等。本文将详细讲解计算器类页面的通用布局设计、组件封装、交互优化技巧以及动画效果实现。

为什么需要统一的界面设计?

  1. 用户体验一致性:统一的设计风格让用户更容易上手
  2. 开发效率提升:组件复用减少重复代码
  3. 维护成本降低:统一的架构便于后期维护
  4. 品牌形象塑造:一致的设计语言强化品牌认知

计算器模块

界面设计

布局结构

组件封装

交互优化

用户体验

响应速度

操作便捷

视觉反馈

一、界面布局设计原则

1.1 通用布局结构

计算器类页面通常采用以下布局结构:

┌─────────────────────────────────┐
│          导航栏                 │
├─────────────────────────────────┤
│                                 │
│        输入区域                 │
│                                 │
├─────────────────────────────────┤
│                                 │
│        结果展示区域             │
│                                 │
├─────────────────────────────────┤
│                                 │
│        操作按钮区域             │
│                                 │
└─────────────────────────────────┘

1.2 布局实现

@Entry
@Component
struct CalculatorPage {
  build() {
    Column() {
      // 导航栏(固定高度)
      this.NavigationBar()
      
      // 可滚动内容区
      Scroll() {
        Column() {
          // 输入区域
          this.InputSection()
          
          // 结果展示区域
          this.ResultSection()
          
          // 操作按钮区域
          this.ActionSection()
        }
        .padding(16)
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

1.3 响应式布局

@Component
struct ResponsiveLayout {
  @State deviceType: DeviceType = this.getDeviceType();
  
  build() {
    if (this.deviceType === DeviceType.PHONE) {
      this.PhoneLayout()
    } else if (this.deviceType === DeviceType.TABLET) {
      this.TabletLayout()
    } else {
      this.DesktopLayout()
    }
  }
  
  private getDeviceType(): DeviceType {
    const width = px2vp(display.getDefaultDisplaySync().width);
    if (width < 600) return DeviceType.PHONE;
    if (width < 900) return DeviceType.TABLET;
    return DeviceType.DESKTOP;
  }
}

<600px

600-900px

>900px

设备检测

屏幕宽度

手机布局

平板布局

桌面布局

二、通用组件封装

2.1 输入组件封装

/**
 * 通用输入组件
 * 支持多种输入类型和验证
 */
@Component
export struct InputField {
  @Prop label: string;
  @Prop placeholder: string = '';
  @Prop unit: string = '';
  @Prop hint: string = '';
  @Link value: string;
  @Prop inputType: InputType = InputType.Number;
  @Prop required: boolean = false;
  @Prop minLength: number = 0;
  @Prop maxLength: number = 100;
  @Prop minValue: number = 0;
  @Prop maxValue: number = 999999;
  
  @State showError: boolean = false;
  @State errorMessage: string = '';
  
  build() {
    Column() {
      Row() {
        // 标签
        Row() {
          Text(this.label)
            .fontSize(16)
            .fontColor('#333333')
          
          if (this.required) {
            Text('*')
              .fontSize(16)
              .fontColor('#FF0000')
              .margin({ left: 4 })
          }
        }
        .width(100)
        
        // 输入框
        TextInput({ placeholder: this.placeholder, text: this.value })
          .type(this.inputType)
          .layoutWeight(1)
          .height(40)
          .borderRadius(4)
          .border({ width: 1, color: this.showError ? '#FF0000' : '#E0E0E0' })
          .onChange((value: string) => {
            this.handleInput(value);
          })
        
        // 单位
        if (this.unit) {
          Text(this.unit)
            .fontSize(14)
            .fontColor('#999999')
            .margin({ left: 8 })
        }
      }
      .width('100%')
      
      // 提示信息
      if (this.hint && !this.showError) {
        Text(this.hint)
          .fontSize(12)
          .fontColor('#999999')
          .margin({ top: 4, left: 100 })
      }
      
      // 错误信息
      if (this.showError) {
        Text(this.errorMessage)
          .fontSize(12)
          .fontColor('#FF0000')
          .margin({ top: 4, left: 100 })
      }
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
  }
  
  private handleInput(value: string): void {
    // 输入验证
    const validated = this.validateInput(value);
    this.value = validated;
  }
  
  private validateInput(value: string): string {
    // 数字验证
    if (this.inputType === InputType.Number) {
      value = value.replace(/[^\d.-]/g, '');
    }
    
    // 长度限制
    if (value.length > this.maxLength) {
      value = value.substring(0, this.maxLength);
    }
    
    // 范围验证
    const numValue = parseFloat(value);
    if (!isNaN(numValue)) {
      if (numValue < this.minValue) {
        this.showError = true;
        this.errorMessage = `最小值为${this.minValue}`;
      } else if (numValue > this.maxValue) {
        this.showError = true;
        this.errorMessage = `最大值为${this.maxValue}`;
      } else {
        this.showError = false;
      }
    }
    
    return value;
  }
}

2.2 选择器组件

/**
 * 选项选择器组件
 */
@Component
export struct OptionSelector {
  @Prop label: string;
  @Prop options: OptionItem[];
  @Link selectedValue: string | number;
  
  build() {
    Column() {
      Text(this.label)
        .fontSize(16)
        .fontColor('#333333')
        .margin({ bottom: 12 })
      
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(this.options, (option: OptionItem) => {
          Button(option.label)
            .fontSize(14)
            .fontColor(this.selectedValue === option.value ? '#FFFFFF' : '#333333')
            .backgroundColor(this.selectedValue === option.value ? '#4A90E2' : '#F0F0F0')
            .borderRadius(8)
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .margin({ right: 8, bottom: 8 })
            .onClick(() => {
              this.selectedValue = option.value;
            })
        })
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
  }
}

interface OptionItem {
  label: string;
  value: string | number;
}

2.3 结果展示组件

/**
 * 结果展示组件
 */
@Component
export struct ResultDisplay {
  @Prop title: string = '计算结果';
  @Prop mainValue: string;
  @Prop mainUnit: string = '';
  @Prop subItems: ResultSubItem[] = [];
  @State opacity: number = 0;
  
  aboutToAppear() {
    // 淡入动画
    animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
      this.opacity = 1;
    });
  }
  
  build() {
    Column() {
      // 标题
      Text(this.title)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ bottom: 16 })
      
      // 主要结果
      Column() {
        Row() {
          Text(this.mainValue)
            .fontSize(36)
            .fontColor('#FF6B6B')
            .fontWeight(FontWeight.Bold)
          
          if (this.mainUnit) {
            Text(this.mainUnit)
              .fontSize(16)
              .fontColor('#666666')
              .margin({ left: 4 })
          }
        }
      }
      .width('100%')
      .padding(24)
      .backgroundColor('#FFF5F5')
      .borderRadius(12)
      
      // 子项展示
      if (this.subItems.length > 0) {
        Row() {
          ForEach(this.subItems, (item: ResultSubItem, index: number) => {
            Column() {
              Text(item.label)
                .fontSize(12)
                .fontColor('#999999')
              
              Text(item.value)
                .fontSize(18)
                .fontColor('#333333')
                .fontWeight(FontWeight.Medium)
                .margin({ top: 4 })
            }
            .layoutWeight(1)
            
            if (index < this.subItems.length - 1) {
              Divider()
                .vertical(true)
                .height(40)
            }
          })
        }
        .width('100%')
        .padding(16)
        .backgroundColor('#FFFFFF')
        .borderRadius(12)
        .margin({ top: 16 })
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .opacity(this.opacity)
  }
}

interface ResultSubItem {
  label: string;
  value: string;
}

三、交互优化技巧

3.1 实时计算

@Component
struct RealtimeCalculator {
  @State value1: string = '';
  @State value2: string = '';
  @State result: number = 0;
  
  // 使用 @Watch 监听变化
  @Watch('onValueChange')
  @State watchValue1: string = '';
  
  @Watch('onValueChange')
  @State watchValue2: string = '';
  
  aboutToAppear() {
    this.watchValue1 = this.value1;
    this.watchValue2 = this.value2;
  }
  
  onValueChange() {
    // 防抖处理
    clearTimeout(this.timer);
    this.timer = setTimeout(() => {
      this.calculate();
    }, 300);
  }
  
  private timer: number = 0;
  
  calculate() {
    const v1 = parseFloat(this.value1);
    const v2 = parseFloat(this.value2);
    
    if (!isNaN(v1) && !isNaN(v2)) {
      this.result = v1 + v2;
    }
  }
}

3.2 输入验证

export class InputValidator {
  /**
   * 数字输入验证
   */
  static validateNumber(value: string): string {
    return value
      .replace(/[^\d.-]/g, '')        // 只允许数字、小数点、负号
      .replace(/\.{2,}/g, '.')        // 只允许一个小数点
      .replace(/(\d*\.?\d*)\..*/, '$1') // 移除多余的小数点
      .replace(/^-/, '')              // 移除开头的负号(如果需要)
      .replace(/(\..*)\./, '$1');     // 移除小数点后的多余小数点
  }
  
  /**
   * 范围验证
   */
  static validateRange(value: number, min: number, max: number): number {
    return Math.max(min, Math.min(max, value));
  }
  
  /**
   * 整数验证
   */
  static validateInteger(value: string): string {
    return value.replace(/[^\d-]/g, '').replace(/(?!^)-/g, '');
  }
  
  /**
   * 金额验证(保留两位小数)
   */
  static validateMoney(value: string): string {
    let result = this.validateNumber(value);
    
    // 限制小数位数为2位
    const parts = result.split('.');
    if (parts.length > 1 && parts[1].length > 2) {
      result = parts[0] + '.' + parts[1].substring(0, 2);
    }
    
    return result;
  }
}

3.3 防抖与节流

/**
 * 防抖函数
 */
export function debounce(fn: Function, delay: number): Function {
  let timer: number = 0;
  return function(...args: any[]) {
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
    }, delay);
  };
}

/**
 * 节流函数
 */
export function throttle(fn: Function, delay: number): Function {
  let lastTime: number = 0;
  return function(...args: any[]) {
    const now = Date.now();
    if (now - lastTime >= delay) {
      fn.apply(this, args);
      lastTime = now;
    }
  };
}

// 使用示例
@Component
struct OptimizedInput {
  @State value: string = '';
  
  private debouncedCalculate = debounce(() => {
    this.calculate();
  }, 300);
  
  handleInput(value: string) {
    this.value = value;
    this.debouncedCalculate();
  }
  
  calculate() {
    // 计算逻辑
  }
}

四、动画效果实现

4.1 结果展示动画

@Component
struct AnimatedResult {
  @Prop value: number = 0;
  @State displayValue: number = 0;
  @State scale: number = 0.8;
  @State opacity: number = 0;
  
  aboutToAppear() {
    // 数值滚动动画
    this.animateNumber();
    
    // 缩放淡入动画
    animateTo({ duration: 300, curve: Curve.Spring }, () => {
      this.scale = 1;
      this.opacity = 1;
    });
  }
  
  private animateNumber() {
    const duration = 1000;
    const startTime = Date.now();
    const startValue = 0;
    const endValue = this.value;
    
    const animate = () => {
      const elapsed = Date.now() - startTime;
      const progress = Math.min(elapsed / duration, 1);
      
      // 缓动函数
      const easeOut = 1 - Math.pow(1 - progress, 3);
      
      this.displayValue = startValue + (endValue - startValue) * easeOut;
      
      if (progress < 1) {
        requestAnimationFrame(animate);
      }
    };
    
    requestAnimationFrame(animate);
  }
  
  build() {
    Text(`${Math.round(this.displayValue)}`)
      .fontSize(36)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FF6B6B')
      .scale({ x: this.scale, y: this.scale })
      .opacity(this.opacity)
  }
}

4.2 按钮点击动画

@Component
struct AnimatedButton {
  @Prop text: string;
  @State scale: number = 1;
  @State rippleScale: number = 0;
  @State rippleOpacity: number = 0;
  
  build() {
    Stack() {
      // 按钮背景
      Button(this.text)
        .width('100%')
        .height(48)
        .fontSize(16)
        .backgroundColor('#4A90E2')
        .borderRadius(24)
        .scale({ x: this.scale, y: this.scale })
        .onClick(() => {
          this.handleClick();
        })
      
      // 涟漪效果
      Circle()
        .width(200)
        .height(200)
        .fill('rgba(255, 255, 255, 0.3)')
        .scale({ x: this.rippleScale, y: this.rippleScale })
        .opacity(this.rippleOpacity)
    }
    .width('100%')
    .height(48)
  }
  
  private handleClick() {
    // 缩放动画
    animateTo({ duration: 100 }, () => {
      this.scale = 0.95;
    });
    
    setTimeout(() => {
      animateTo({ duration: 100 }, () => {
        this.scale = 1;
      });
    }, 100);
    
    // 涟漪动画
    this.rippleScale = 0;
    this.rippleOpacity = 1;
    
    animateTo({ duration: 600 }, () => {
      this.rippleScale = 2;
      this.rippleOpacity = 0;
    });
  }
}

4.3 页面切换动画

@Component
struct PageTransition {
  @State opacity: number = 1;
  @State translate: number = 0;
  
  pageTransition() {
    PageTransitionEnter({ duration: 300, curve: Curve.EaseOut })
      .onEnter(() => {
        this.opacity = 0;
        this.translate = 100;
        
        animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
          this.opacity = 1;
          this.translate = 0;
        });
      })
    
    PageTransitionExit({ duration: 300, curve: Curve.EaseIn })
      .onExit(() => {
        animateTo({ duration: 300, curve: Curve.EaseIn }, () => {
          this.opacity = 0;
          this.translate = -100;
        });
      })
  }
  
  build() {
    Column() {
      // 页面内容
    }
    .opacity(this.opacity)
    .translate({ x: this.translate })
  }
}

用户操作

触发动画

缩放动画

淡入淡出

涟漪效果

视觉反馈

五、无障碍支持

5.1 语义化标注

@Component
struct AccessibleInput {
  @Prop label: string;
  @Link value: string;
  
  build() {
    Row() {
      Text(this.label)
        .accessibilityText(this.label)
        .accessibilityRole(AccessibilityRole.TEXT)
      
      TextInput({ text: this.value })
        .accessibilityText(`${this.label}输入框`)
        .accessibilityRole(AccessibilityRole.EDITBOX)
        .accessibilityLevel('important')
        .onChange((value: string) => {
          this.value = value;
        })
    }
  }
}

5.2 屏幕阅读器支持

@Component
struct AccessibleResult {
  @Prop result: number;
  
  build() {
    Column() {
      Text(`${this.result}`)
        .fontSize(36)
        .accessibilityText(`计算结果为${this.result}`)
        .accessibilityRole(AccessibilityRole.TEXT)
        .accessibilityLevel('important')
        .onAccessibility(() => {
          // 屏幕阅读器激活时的回调
          console.info('Result announced');
        })
    }
  }
}

5.3 键盘导航

@Component
struct KeyboardNavigation {
  @State focusIndex: number = 0;
  private inputs: string[] = ['', '', ''];
  
  build() {
    Column() {
      ForEach([0, 1, 2], (index: number) => {
        TextInput({ text: this.inputs[index] })
          .width('100%')
          .height(48)
          .margin({ bottom: 16 })
          .defaultFocus(index === 0)
          .onKeyEvent((event: KeyEvent) => {
            if (event.keyCode === KeyCode.KEYCODE_TAB) {
              // Tab键切换焦点
              this.focusIndex = (this.focusIndex + 1) % 3;
            }
          })
          .onChange((value: string) => {
            this.inputs[index] = value;
          })
      })
    }
  }
}

六、性能优化

6.1 渲染优化

@Component
struct OptimizedList {
  @State items: DataItem[] = [];
  
  build() {
    List() {
      ForEach(
        this.items,
        (item: DataItem) => {
          ListItem() {
            this.ItemView(item)
          }
        },
        (item: DataItem) => item.id.toString()
      )
    }
    .width('100%')
    .height('100%')
    .cachedCount(5)  // 缓存预加载项数
    .lazy(true)      // 懒加载
  }
  
  @Builder
  ItemView(item: DataItem) {
    Row() {
      Text(item.name).fontSize(16)
    }
    .width('100%')
    .height(60)
  }
}

6.2 状态管理优化

// 使用 @ObjectLink 避免深拷贝
@Observed
class CalculatorState {
  value1: string = '';
  value2: string = '';
  result: number = 0;
}

@Component
struct OptimizedCalculator {
  @ObjectLink state: CalculatorState;
  
  build() {
    Column() {
      TextInput({ text: this.state.value1 })
        .onChange((v) => { this.state.value1 = v; })
      
      TextInput({ text: this.state.value2 })
        .onChange((v) => { this.state.value2 = v; })
      
      Text(`${this.state.result}`)
    }
  }
}

6.3 计算优化

export class CalculationCache {
  private cache: Map<string, any> = new Map();
  
  get(key: string): any | null {
    return this.cache.get(key) || null;
  }
  
  set(key: string, value: any): void {
    // LRU缓存策略
    if (this.cache.size >= 100) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
  
  generateKey(...params: any[]): string {
    return params.join('_');
  }
}

七、测试用例

7.1 组件测试

describe('InputField', () => {
  it('should validate number input', () => {
    const validator = new InputValidator();
    const result = validator.validateNumber('abc123.45');
    expect(result).toBe('123.45');
  });
  
  it('should limit decimal places', () => {
    const validator = new InputValidator();
    const result = validator.validateMoney('123.456');
    expect(result).toBe('123.45');
  });
  
  it('should validate range', () => {
    const validator = new InputValidator();
    const result = validator.validateRange(150, 0, 100);
    expect(result).toBe(100);
  });
});

7.2 交互测试

describe('Interaction', () => {
  it('should debounce input', (done) => {
    let callCount = 0;
    const debouncedFn = debounce(() => {
      callCount++;
    }, 100);
    
    debouncedFn();
    debouncedFn();
    debouncedFn();
    
    expect(callCount).toBe(0);
    
    setTimeout(() => {
      expect(callCount).toBe(1);
      done();
    }, 150);
  });
});

八、实际应用案例

8.1 BMI计算器

@Entry
@Component
struct BMICalculator {
  @State height: string = '';
  @State weight: string = '';
  @State bmi: number = 0;
  
  build() {
    Column() {
      this.NavigationBar()
      
      Column() {
        InputField({
          label: '身高',
          unit: 'cm',
          value: $height,
          placeholder: '请输入身高'
        })
        
        InputField({
          label: '体重',
          unit: 'kg',
          value: $weight,
          placeholder: '请输入体重'
        })
        
        Button('计算BMI')
          .onClick(() => this.calculate())
        
        if (this.bmi > 0) {
          ResultDisplay({
            mainValue: this.bmi.toFixed(1),
            mainUnit: '',
            title: 'BMI指数'
          })
        }
      }
      .padding(16)
    }
  }
  
  calculate() {
    const h = parseFloat(this.height) / 100;
    const w = parseFloat(this.weight);
    
    if (h > 0 && w > 0) {
      this.bmi = w / (h * h);
    }
  }
}

8.2 贷款计算器

@Entry
@Component
struct LoanCalculator {
  @State principal: string = '';
  @State rate: string = '';
  @State months: string = '';
  @State monthlyPayment: number = 0;
  
  build() {
    Column() {
      // 输入区域
      InputField({ label: '贷款金额', unit: '元', value: $principal })
      InputField({ label: '年利率', unit: '%', value: $rate })
      InputField({ label: '贷款期限', unit: '月', value: $months })
      
      // 计算按钮
      Button('计算月供').onClick(() => this.calculate())
      
      // 结果展示
      if (this.monthlyPayment > 0) {
        ResultDisplay({
          mainValue: this.monthlyPayment.toFixed(2),
          mainUnit: '元/月',
          title: '每月还款'
        })
      }
    }
  }
  
  calculate() {
    const P = parseFloat(this.principal);
    const r = parseFloat(this.rate) / 100 / 12;
    const n = parseFloat(this.months);
    
    // 等额本息公式
    this.monthlyPayment = P * r * Math.pow(1 + r, n) / (Math.pow(1 + r, n) - 1);
  }
}

计算器类型

BMI计算器

贷款计算器

养老金计算器

日期计算器

通用组件

InputField

ResultDisplay

OptionSelector

九、最佳实践总结

9.1 设计原则

  1. 一致性:统一的视觉风格和交互方式
  2. 简洁性:界面简洁,操作直观
  3. 反馈性:及时的用户操作反馈
  4. 容错性:友好的错误提示和处理
  5. 可访问性:支持无障碍访问

9.2 性能要点

  1. 懒加载:列表数据懒加载
  2. 缓存策略:计算结果缓存
  3. 防抖节流:频繁操作的优化
  4. 状态管理:合理的状态管理策略
  5. 动画优化:使用硬件加速

9.3 代码质量

  1. 组件复用:封装通用组件
  2. 类型安全:使用TypeScript类型
  3. 单元测试:完善的测试覆盖
  4. 文档注释:清晰的代码注释
  5. 错误处理:完善的异常处理

十、总结

10.1 技术要点回顾

  1. 布局设计:统一的页面结构设计
  2. 组件封装:可复用的通用组件
  3. 交互优化:流畅的用户体验
  4. 动画效果:生动的视觉反馈
  5. 性能优化:高效的渲染和计算

10.2 核心代码统计

  • 通用组件:400行
  • 交互优化:200行
  • 动画效果:150行
  • 性能优化:100行
  • 总计:约850行代码

10.3 实现效果

通过本文的实现,我们完成了:

  • ✅ 统一的计算器界面设计
  • ✅ 可复用的通用组件库
  • ✅ 流畅的交互动画
  • ✅ 完善的无障碍支持
  • ✅ 优秀的性能表现

10.4 未来展望

后续可以考虑的扩展方向:

  1. 主题定制:支持多种主题风格
  2. 手势操作:支持手势快捷操作
  3. 语音输入:支持语音输入数据
  4. 智能提示:输入时的智能建议
  5. 数据可视化:结果的可视化展示

界面设计与优化

布局设计

组件封装

交互优化

动画效果

性能优化

统一结构

可复用性

用户体验

视觉反馈

高效渲染

系列文章导航
下期预告:鸿蒙多功能工具箱开发实战(十一)-公历转农历核心算法实现

Logo

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

更多推荐