HarmonyOS鸿蒙Next开发者技术支持-软键盘避让案例

HarmonyOS鸿蒙Next开发者技术支持-软键盘避让案例

1. 案例概述

1.1 问题背景

在移动应用开发中,当用户在输入框中输入内容时,系统软键盘会自动从屏幕底部弹出。在手机等小屏幕设备上,软键盘通常会占据30%-50%的屏幕高度,导致以下问题:

  1. 输入框被遮挡:用户看不到正在输入的内容
  2. 操作按钮被遮挡:提交、下一步等关键按钮不可见
  3. 页面布局混乱:未考虑键盘高度的固定布局会被打乱
  4. 用户体验差:需要手动滚动或关闭键盘才能看到完整内容

1.2 解决方案概述

本案例提供完整的软键盘避让解决方案,通过以下核心机制实现:

  1. 键盘事件监听:实时监听软键盘的显示和隐藏
  2. 动态布局调整:根据键盘状态自动调整页面布局
  3. 智能滚动定位:确保当前输入框始终在可视区域内
  4. 平滑动画过渡:使用动画实现自然的布局变化
  5. 边界情况处理:处理各种特殊场景和边缘情况

2. 实现步骤详解

步骤1:定义数据模型和工具类

目的:为软键盘避让功能建立完整的数据结构和配置体系,为后续功能实现提供类型安全和配置支持。

核心功能说明:

  1. 键盘信息管理:定义键盘状态的数据结构,包括高度、可见性、动画信息等
  2. 输入框信息管理:管理页面上所有输入框的位置、状态和验证规则
  3. 屏幕尺寸计算:精确计算不同设备的可用屏幕空间,考虑安全区域
  4. 滚动状态管理:支持动画滚动和位置追踪
  5. 统一配置管理:集中管理所有可调参数,便于维护和优化

关键技术点:

  • 使用TypeScript接口确保类型安全
  • 考虑不同设备的屏幕差异(刘海屏、圆角屏、虚拟导航栏)
  • 配置参数分离,便于适配不同需求
  • 支持动画效果配置,提升用户体验

步骤说明:首先定义软键盘避让功能所需的核心数据结构和配置类,这是整个系统的基础。

步骤1实现代码:

// KeyboardAvoidModels.ets
export interface KeyboardInfo {
  height: number;           // 键盘的实际高度(像素)
  width: number;           // 键盘的宽度(像素)
  isVisible: boolean;      // 键盘当前是否可见
  appearanceTime: number;  // 键盘显示的时间戳(用于性能分析)
  duration: number;        // 键盘动画持续时间(毫秒)
  curve: number;           // 键盘动画曲线类型
}

export interface InputFieldInfo {
  id: string;             // 输入框的唯一标识符
  type: string;           // 输入框类型(text, password, number, email等)
  placeholder: string;    // 占位符文本
  isFocused: boolean;     // 当前是否获得焦点
  position: {            // 输入框在屏幕上的位置信息
    x: number;          // 左上角X坐标
    y: number;          // 左上角Y坐标
    width: number;      // 宽度
    height: number;     // 高度
  };
  required: boolean;      // 是否为必填项
  validation?: (value: string) => boolean; // 自定义验证函数
}

export interface LayoutMetrics {
  screenWidth: number;    // 屏幕的总宽度
  screenHeight: number;   // 屏幕的总高度
  safeAreaTop: number;    // 安全区域顶部偏移(状态栏、刘海等)
  safeAreaBottom: number; // 安全区域底部偏移(虚拟导航栏等)
  statusBarHeight: number; // 状态栏高度
  navigationBarHeight: number; // 导航栏高度
  availableHeight: number; // 实际可用的内容高度
}

export interface ScrollPosition {
  offsetY: number;        // 当前的垂直滚动偏移量
  targetOffsetY: number;  // 目标滚动偏移量(用于动画)
  isScrolling: boolean;   // 是否正在执行滚动动画
  animationDuration: number; // 动画持续时间
}

export class KeyboardAvoidConfig {
  static readonly SCROLL_CONFIG = {
    duration: 300,                    // 滚动动画持续时间,300ms提供平滑体验
    curve: Curve.EaseOut,            // 缓出动画曲线,结束时更自然
    offsetPadding: 20,               // 偏移量内边距,确保输入框不被刚好遮挡
    minOffset: 0,                    // 最小滚动偏移,不能向上滚动
    smoothScrolling: true,           // 启用平滑滚动,提升用户体验
    scrollBehavior: 'smooth' as const // 滚动行为类型
  };
  
  static readonly KEYBOARD_CONFIG = {
    defaultHeight: 300,              // 默认键盘高度,用于初始化
    minHeight: 200,                  // 最小键盘高度,兼容小键盘
    maxHeight: 400,                  // 最大键盘高度,兼容大键盘
    detectInterval: 100,             // 检测间隔,平衡性能和实时性
    animationDuration: 250,          // 键盘动画持续时间
    enableAutoAdjust: true           // 启用自动调整
  };
  
  static readonly INPUT_CONFIG = {
    focusAnimation: true,            // 焦点切换动画
    highlightColor: '#0066FF20',     // 高亮颜色(带透明度)
    borderColor: '#E0E0E0',          // 默认边框颜色
    focusedBorderColor: '#0066FF',   // 获得焦点时的边框颜色
    errorBorderColor: '#FF3B30',     // 错误时的边框颜色
    padding: 16,                     // 内边距
    margin: 8                        // 外边距
  };
  
  static readonly PERFORMANCE_CONFIG = {
    throttleDelay: 16,               // 节流延迟,约60fps(1000/60≈16.67)
    debounceDelay: 100,              // 防抖延迟,处理连续快速操作
    enableCache: true,               // 启用缓存,提高重复计算性能
    maxCacheSize: 10,                // 最大缓存数量,避免内存泄漏
    enableLogging: false             // 启用日志,调试时开启
  };
}

步骤2:实现键盘避让管理器

目的:实现整个键盘避让功能的核心协调者,采用单例模式管理所有键盘相关状态,协调各个组件工作。

核心功能说明:

  1. 单例管理:确保全局只有一个管理器实例,统一状态管理
  2. 事件监听:监听系统键盘事件、窗口变化事件
  3. 输入框管理:注册、更新、管理所有输入框状态
  4. 智能计算:根据键盘状态和输入框位置计算需要滚动的距离
  5. 动画控制:实现平滑的滚动动画效果
  6. 回调机制:支持多组件监听键盘事件

关键技术点:

  • 使用HarmonyOS的keyboard API监听键盘事件
  • 实时计算输入框位置和屏幕可用空间
  • 智能判断是否需要滚动以及滚动距离
  • 缓动函数实现自然的动画效果
  • 节流防抖优化性能

步骤说明:这是整个键盘避让功能的核心,负责协调所有组件的工作,采用单例模式确保全局状态一致。

步骤2实现代码:

// KeyboardAvoidManager.ets
import { keyboard } from '[@kit](/user/kit).ArkUI';
import { display } from '[@kit](/user/kit).ArkUI';
import { window } from '[@kit](/user/kit).ArkUI';
import { hilog } from '[@kit](/user/kit).PerformanceAnalysisKit';

export class KeyboardAvoidManager {
  private static instance: KeyboardAvoidManager;
  private keyboardHeight: number = 0;
  private isKeyboardVisible: boolean = false;
  private lastKeyboardHeight: number = 0;
  private scrollOffset: number = 0;
  private targetOffset: number = 0;
  private isAnimating: boolean = false;
  private onKeyboardShowCallbacks: Array<(height: number) => void> = [];
  private onKeyboardHideCallbacks: Array<() => void> = [];
  private onScrollCallbacks: Array<(offset: number) => void> = [];
  private inputFields: Map<string, InputFieldInfo> = new Map();
  private focusedInputId: string | null = null;
  private screenMetrics: LayoutMetrics = {
    screenWidth: 0,
    screenHeight: 0,
    safeAreaTop: 0,
    safeAreaBottom: 0,
    statusBarHeight: 0,
    navigationBarHeight: 0,
    availableHeight: 0
  };

  private constructor() {
    this.initialize();
  }

  public static getInstance(): KeyboardAvoidManager {
    if (!KeyboardAvoidManager.instance) {
      KeyboardAvoidManager.instance = new KeyboardAvoidManager();
    }
    return KeyboardAvoidManager.instance;
  }

  private initialize(): void {
    this.initScreenMetrics();
    this.setupKeyboardListeners();
    this.setupWindowListeners();
    hilog.info(0x0000, 'KeyboardAvoidManager', '键盘避让管理器初始化完成');
  }

  private initScreenMetrics(): void {
    try {
      const displayInfo = display.getDefaultDisplaySync();
      this.screenMetrics.screenWidth = displayInfo.width;
      this.screenMetrics.screenHeight = displayInfo.height;
      
      const windowClass = window.getLastWindow(getContext(this) as common.UIAbilityContext);
      if (windowClass) {
        const avoidArea = windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
        this.screenMetrics.safeAreaTop = avoidArea.topRect.height;
        this.screenMetrics.safeAreaBottom = avoidArea.bottomRect.height;
      }
      
      this.screenMetrics.availableHeight = 
        this.screenMetrics.screenHeight - 
        this.screenMetrics.safeAreaTop - 
        this.screenMetrics.safeAreaBottom;
      
      hilog.info(0x0000, 'KeyboardAvoidManager', 
        `屏幕信息: 宽度=${this.screenMetrics.screenWidth}, 高度=${this.screenMetrics.screenHeight}, 可用高度=${this.screenMetrics.availableHeight}`);
    } catch (error) {
      hilog.error(0x0000, 'KeyboardAvoidManager', '获取屏幕信息失败: ' + JSON.stringify(error));
    }
  }

  private setupKeyboardListeners(): void {
    try {
      keyboard.onAppear(() => {
        hilog.info(0x0000, 'KeyboardAvoidManager', '键盘显示');
        this.handleKeyboardAppear();
      });
      
      keyboard.onDisappear(() => {
        hilog.info(0x0000, 'KeyboardAvoidManager', '键盘隐藏');
        this.handleKeyboardDisappear();
      });
      
      keyboard.onChange((height: number) => {
        hilog.info(0x0000, 'KeyboardAvoidManager', `键盘高度变化: ${height}`);
        this.handleKeyboardHeightChange(height);
      });
      
      hilog.info(0x0000, 'KeyboardAvoidManager', '键盘事件监听器设置完成');
    } catch (error) {
      hilog.error(0x0000, 'KeyboardAvoidManager', '设置键盘监听器失败: ' + JSON.stringify(error));
    }
  }

  private setupWindowListeners(): void {
    try {
      const context = getContext(this) as common.UIAbilityContext;
      const windowClass = window.getLastWindow(context);
      
      if (windowClass) {
        windowClass.on('windowSizeChange', (windowSize: window.Size) => {
          this.handleWindowSizeChange(windowSize);
        });
        
        windowClass.on('windowFocus', () => {
          this.handleWindowFocus();
        });
        
        windowClass.on('windowUnfocus', () => {
          this.handleWindowUnfocus();
        });
      }
    } catch (error) {
      hilog.error(0x0000, 'KeyboardAvoidManager', '设置窗口监听器失败: ' + JSON.stringify(error));
    }
  }

  private handleKeyboardAppear(): void {
    this.isKeyboardVisible = true;
    
    if (this.keyboardHeight === 0) {
      this.keyboardHeight = KeyboardAvoidConfig.KEYBOARD_CONFIG.defaultHeight;
    }
    
    hilog.info(0x0000, 'KeyboardAvoidManager', `键盘显示,高度: ${this.keyboardHeight}`);
    
    this.onKeyboardShowCallbacks.forEach(callback => {
      callback(this.keyboardHeight);
    });
    
    if (this.focusedInputId) {
      this.adjustForFocusedInput();
    }
  }

  private handleKeyboardDisappear(): void {
    this.isKeyboardVisible = false;
    this.lastKeyboardHeight = this.keyboardHeight;
    this.keyboardHeight = 0;
    
    hilog.info(0x0000, 'KeyboardAvoidManager', '键盘隐藏');
    
    this.onKeyboardHideCallbacks.forEach(callback => {
      callback();
    });
    
    this.scrollToPosition(0, true);
  }

  private handleKeyboardHeightChange(height: number): void {
    if (height > 0 && height !== this.keyboardHeight) {
      this.keyboardHeight = height;
      hilog.info(0x0000, 'KeyboardAvoidManager', `键盘高度更新: ${height}`);
      
      if (this.isKeyboardVisible && this.focusedInputId) {
        this.adjustForFocusedInput();
      }
    }
  }

  private handleWindowSizeChange(windowSize: window.Size): void {
    this.screenMetrics.screenWidth = windowSize.width;
    this.screenMetrics.screenHeight = windowSize.height;
    
    this.screenMetrics.availableHeight = 
      this.screenMetrics.screenHeight - 
      this.screenMetrics.safeAreaTop - 
      this.screenMetrics.safeAreaBottom;
    
    hilog.info(0x0000, 'KeyboardAvoidManager', 
      `窗口尺寸变化: ${windowSize.width}x${windowSize.height}, 可用高度: ${this.screenMetrics.availableHeight}`);
    
    if (this.isKeyboardVisible && this.focusedInputId) {
      this.adjustForFocusedInput();
    }
  }

  private adjustForFocusedInput(): void {
    if (!this.focusedInputId || !this.inputFields.has(this.focusedInputId)) {
      return;
    }
    
    const inputInfo = this.inputFields.get(this.focusedInputId)!;
    const inputBottom = inputInfo.position.y + inputInfo.position.height;
    const visibleAreaTop = this.screenMetrics.safeAreaTop;
    const visibleAreaBottom = this.screenMetrics.availableHeight - this.keyboardHeight;
    
    if (inputBottom > visibleAreaBottom) {
      const requiredOffset = inputBottom - visibleAreaBottom + KeyboardAvoidConfig.SCROLL_CONFIG.offsetPadding;
      
      hilog.info(0x0000, 'KeyboardAvoidManager', 
        `输入框被遮挡,需要滚动偏移: ${requiredOffset}, 输入框底部: ${inputBottom}, 可视区域底部: ${visibleAreaBottom}`);
      
      this.scrollToPosition(requiredOffset, true);
    } else if (inputInfo.position.y < visibleAreaTop) {
      const requiredOffset = inputInfo.position.y - visibleAreaTop - KeyboardAvoidConfig.SCROLL_CONFIG.offsetPadding;
      
      hilog.info(0x0000, 'KeyboardAvoidManager', 
        `输入框在上方,需要滚动偏移: ${requiredOffset}`);
      
      this.scrollToPosition(requiredOffset, true);
    } else {
      hilog.info(0x0000, 'KeyboardAvoidManager', '输入框在可视区域内,不需要滚动');
    }
  }

  private scrollToPosition(offset: number, animated: boolean = true): void {
    const maxOffset = this.calculateMaxScrollOffset();
    const targetOffset = Math.max(KeyboardAvoidConfig.SCROLL_CONFIG.minOffset, Math.min(offset, maxOffset));
    
    if (targetOffset === this.scrollOffset) {
      return;
    }
    
    this.targetOffset = targetOffset;
    
    if (animated) {
      this.startScrollAnimation();
    } else {
      this.scrollOffset = targetOffset;
      this.notifyScrollCallbacks();
    }
    
    hilog.info(0x0000, 'KeyboardAvoidManager', 
      `滚动到位置: ${targetOffset}, 动画: ${animated}, 最大偏移: ${maxOffset}`);
  }

  private calculateMaxScrollOffset(): number {
    return 1000;
  }

  private startScrollAnimation(): void {
    if (this.isAnimating) {
      return;
    }
    
    this.isAnimating = true;
    const startOffset = this.scrollOffset;
    const endOffset = this.targetOffset;
    const duration = KeyboardAvoidConfig.SCROLL_CONFIG.duration;
    const startTime = Date.now();
    
    const animate = () => {
      const currentTime = Date.now();
      const elapsed = currentTime - startTime;
      const progress = Math.min(elapsed / duration, 1);
      
      const easedProgress = this.easeOutCubic(progress);
      this.scrollOffset = startOffset + (endOffset - startOffset) * easedProgress;
      
      this.notifyScrollCallbacks();
      
      if (progress < 1) {
        setTimeout(animate, 16);
      } else {
        this.scrollOffset = endOffset;
        this.isAnimating = false;
        this.notifyScrollCallbacks();
        hilog.info(0x0000, 'KeyboardAvoidManager', '滚动动画完成');
      }
    };
    
    animate();
  }

  private easeOutCubic(t: number): number {
    return 1 - Math.pow(1 - t, 3);
  }

  public onKeyboardShow(callback: (height: number) => void): void {
    this.onKeyboardShowCallbacks.push(callback);
  }
  
  public onKeyboardHide(callback: () => void): void {
    this.onKeyboardHideCallbacks.push(callback);
  }
  
  public onScroll(callback: (offset: number) => void): void {
    this.onScrollCallbacks.push(callback);
  }
  
  public registerInputField(id: string, info: InputFieldInfo): void {
    this.inputFields.set(id, info);
    hilog.info(0x0000, 'KeyboardAvoidManager', `注册输入框: ${id}, 位置: (${info.position.x}, ${info.position.y})`);
  }
  
  public updateInputFocus(id: string, isFocused: boolean): void {
    if (this.inputFields.has(id)) {
      const info = this.inputFields.get(id)!;
      info.isFocused = isFocused;
      
      if (isFocused) {
        this.focusedInputId = id;
        hilog.info(0x0000, 'KeyboardAvoidManager', `输入框获得焦点: ${id}`);
        
        if (this.isKeyboardVisible) {
          this.adjustForFocusedInput();

更多关于HarmonyOS鸿蒙Next开发者技术支持-软键盘避让案例的实战教程也可以访问 https://www.itying.com/category-93-b0.html

2 回复

鸿蒙Next中软键盘避让可通过设置窗口属性avoidKeyboardtrue实现。在WindowUIAbility中配置windowSoftInputModeadjustResizeadjustPan。使用UIExtensionContentWindow时,需在onWindowStageCreate中调用setWindowAvoidArea方法监听避让区域变化并调整布局。

更多关于HarmonyOS鸿蒙Next开发者技术支持-软键盘避让案例的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


这是一个非常专业和完整的HarmonyOS Next软键盘避让实现案例。您提供的代码和架构清晰地展示了一个企业级解决方案,涵盖了从核心管理、智能组件到具体业务页面的全链路实现。以下是对该方案的技术点评:

方案亮点:

  1. 架构清晰,职责分离:采用Manager(管理器)、Component(组件)、Page(页面)三层架构。KeyboardAvoidManager作为单例中枢,统一管理键盘状态和输入框信息,耦合度低,易于维护和扩展。

  2. 充分利用ArkUI能力

    • 事件监听:正确使用了keyboard.onAppearonDisappearonChange等API来监听键盘事件,这是实现避让的基础。
    • 安全区域:通过window.getWindowAvoidArea获取系统安全区(如刘海、手势条),确保了布局计算的准确性。
    • 组件通信:通过@BuilderParam、回调函数等方式,使AdaptiveScrollSmartInputField组件灵活可配置。
    • 动画与交互:在滚动和焦点切换时使用了动画配置(Curve.EaseOut),并考虑了节流防抖,提升了用户体验和性能。
  3. 实现细节考虑周全

    • 智能滚动计算:在adjustForFocusedInput方法中,不仅计算了输入框是否被键盘遮挡,还考虑了输入框是否位于安全区之上,逻辑严谨。
    • 输入框生命周期管理SmartInputFieldaboutToAppear中自动生成ID并注册,在onAreaChange中实时更新位置,与管理器联动紧密。
    • 容错与日志:关键步骤添加了try-catchhilog日志,便于调试和问题追踪。

潜在优化与注意事项:

  1. calculateMaxScrollOffset方法:在KeyboardAvoidManager中,此方法目前返回固定值1000。在实际应用中,最大滚动偏移量应与滚动内容的总高度超出容器可视高度的部分动态关联。通常需要结合AdaptiveScroll中获取的contentHeight与容器可用高度进行计算。

  2. 位置获取的准确性SmartInputField中通过onAreaChange回调获取全局位置globalPosition是正确的。需要注意的是,确保此回调在布局稳定后触发,对于动态加载或高度变化的输入框(如多行文本),可能需要额外的处理来确保位置信息的及时更新。

  3. 多窗口模式:案例中使用了window.getLastWindow。在HarmonyOS Next支持多窗口的场景下,需要确保获取的是当前UIAbility对应的正确窗口实例,避免跨窗口的状态干扰。

  4. 性能考量

    • onAreaChange回调可能频繁触发。案例中使用了setTimeout延迟更新,这是一种防抖策略。对于复杂页面,可考虑进一步优化,例如仅在输入框获得焦点或其布局确实发生变化时上报位置。
    • 大量输入框同时注册时,Map结构的查找效率很高,但需注意在页面销毁时做好清理工作(destroy方法),防止内存泄漏。
  5. AdaptiveScroll的底部内边距:该组件通过动态调整paddingBottom来为键盘腾出空间,这是一种常见且有效的做法。需要确保页面内没有其他绝对定位的元素因此被错误地顶起。

总结: 您提供的案例远超基础教程水平,是一个可直接用于生产环境的、高质量的参考实现。它展示了在HarmonyOS Next上处理复杂交互问题的完整思路:以原生API为基石,构建可复用的核心管理层,再封装成声明式的UI组件,最终在业务页面中简洁地组合使用。开发者可以以此为基础,根据具体应用场景调整配置参数或扩展功能(如支持自定义避让策略、更复杂的表单联动等)。

回到顶部