从单选到多选:Radio组件自定义的陷阱

在鸿蒙应用开发中,我最近遇到了一个看似简单却让人头疼的问题:一个旅游类应用中,用户需要选择出行人数,我设计了精美的单选按钮组,每个Radio都带有图标和详细描述。代码写完,界面完美,但测试时发现:用户点击后,竟然可以同时选中多个选项!这完全违背了单选按钮的设计初衷。

经过排查,问题出在Radio组件的自定义内容上。当我为Radio添加了ContentModifier来自定义外观后,原本的单选逻辑就失效了。这让我意识到,在鸿蒙开发中,有些组件的自定义需要格外小心,否则会破坏其核心功能。

问题重现:当自定义遇上核心功能

问题场景

让我们先看看出问题的代码。假设我们正在开发一个出行人数选择组件,需要用户从1-4人中选择:

// 错误示例:自定义Radio后单选失效
@Entry
@Component
struct PeopleCountSelector {
  @State selectedCount: number = 1;
  
  build() {
    Column({ space: 20 }) {
      Text('选择出行人数')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20 })
      
      // RadioGroup包装Radio组件
      RadioGroup({ group: 'peopleCount' }) {
        ForEach([1, 2, 3, 4], (count: number) => {
          Radio({ value: count.toString(), group: 'peopleCount' })
            .checked(this.selectedCount === count)
            .onChange((isChecked: boolean) => {
              if (isChecked) {
                this.selectedCount = count;
              }
            })
            // 这里使用了ContentModifier自定义内容
            .contentModifier(this.peopleCountRadioContent(count))
        })
      }
    }
    .padding(20)
  }
  
  // 自定义Radio内容
  @Builder
  peopleCountRadioContent(count: number) {
    Row({ space: 12 }) {
      // 左侧图标
      Image(this.getPeopleIcon(count))
        .width(24)
        .height(24)
      
      // 人数文本
      Text(`${count}人`)
        .fontSize(16)
        .fontWeight(this.selectedCount === count ? 
          FontWeight.Bold : FontWeight.Normal)
      
      // 描述文本
      Text(this.getCountDescription(count))
        .fontSize(12)
        .fontColor(Color.Gray)
    }
    .padding(10)
    .borderRadius(8)
    .border({
      width: this.selectedCount === count ? 2 : 1,
      color: this.selectedCount === count ? 
        '#007DFF' : '#E5E5E5'
    })
    .backgroundColor(
      this.selectedCount === count ? 
      '#F0F8FF' : Color.White
    )
  }
  
  getPeopleIcon(count: number): Resource {
    switch(count) {
      case 1: return $r('app.media.icon_single');
      case 2: return $r('app.media.icon_couple');
      case 3: return $r('app.media.icon_family_small');
      case 4: return $r('app.media.icon_family_large');
      default: return $r('app.media.icon_single');
    }
  }
  
  getCountDescription(count: number): string {
    switch(count) {
      case 1: return '独自旅行,自由自在';
      case 2: return '情侣出游,浪漫双人行';
      case 3: return '家庭旅行,温馨小家庭';
      case 4: return '朋友结伴,欢乐出行';
      default: return '';
    }
  }
}

问题表现:运行这段代码,点击不同的Radio选项,你会发现:

  1. 可以同时选中多个Radio

  2. 选中状态不会自动切换

  3. 视觉反馈不准确

问题本质分析

为什么添加ContentModifier后Radio的单选功能就失效了?让我们深入分析Radio组件的工作原理。

Radio的正常工作流程

// Radio内部简化逻辑
class RadioComponent {
  private value: string;
  private group: string;
  private checked: boolean = false;
  
  // 正常情况下的点击处理
  onClick() {
    if (!this.checked) {
      // 1. 取消同组其他Radio的选中状态
      RadioGroup.cancelOthersInGroup(this.group, this.value);
      
      // 2. 设置当前Radio为选中状态
      this.checked = true;
      
      // 3. 触发onChange事件
      this.onChange?.(true);
      
      // 4. 更新UI
      this.updateUI();
    }
  }
}

添加ContentModifier后的变化

// 使用ContentModifier后的Radio
class CustomRadioComponent extends RadioComponent {
  private contentModifier: Function;
  
  // 重写渲染逻辑
  render() {
    // 使用自定义内容替换默认内容
    const customContent = this.contentModifier();
    
    // 问题:自定义内容可能覆盖了Radio的原始点击区域
    // 默认的点击事件绑定在Radio组件上
    // 但自定义内容可能没有正确继承这些事件
    return customContent;
  }
}

核心问题:当使用ContentModifier时,Radio组件的默认外观和交互逻辑被完全替换。链接1明确指出:“Radio组件添加了ContentModifier后,内容、样式和触发条件需要自己定义。”

这意味着:

  1. 默认的单选逻辑被覆盖

  2. 组内互斥的逻辑需要手动实现

  3. 点击事件的绑定需要重新处理

解决方案:完整实现自定义Radio的单选功能

方案一:基础修复 - 添加点击事件处理

链接1提供的解决方案是:在自定义内容上添加点击事件,手动控制checked状态。让我们完善这个方案:

@Component
struct FixedPeopleCountSelector {
  @State selectedCount: number = 1;
  // 使用数组管理每个Radio的选中状态
  @State radioStates: boolean[] = [true, false, false, false];
  
  build() {
    Column({ space: 20 }) {
      Text('选择出行人数')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20 })
      
      // 不再使用RadioGroup,用Column模拟
      Column({ space: 10 }) {
        ForEach([1, 2, 3, 4], (count: number, index: number) => {
          // 使用自定义的Radio-like组件
          this.CustomRadio(
            count, 
            index,
            this.radioStates[index]
          )
        })
      }
    }
    .padding(20)
  }
  
  // 自定义Radio组件
  @Builder
  CustomRadio(count: number, index: number, isChecked: boolean) {
    // 创建点击区域
    ClickableRegion({ clicks: 1 })
      .onClick(() => {
        // 单选逻辑:先取消所有选中
        this.radioStates = this.radioStates.map(() => false);
        
        // 再设置当前为选中
        this.radioStates[index] = true;
        this.selectedCount = count;
        
        console.log(`选中: ${count}人`);
      })
      .hitTestBehavior(HitTestMode.Default)
      .borderRadius(8)
    {
      // 自定义内容
      Row({ space: 12 }) {
        // 选中状态的图标
        if (isChecked) {
          Image($r('app.media.icon_checked'))
            .width(20)
            .height(20)
        } else {
          Image($r('app.media.icon_unchecked'))
            .width(20)
            .height(20)
        }
        
        // 人数图标
        Image(this.getPeopleIcon(count))
          .width(24)
          .height(24)
        
        Column({ space: 4 }) {
          Text(`${count}人`)
            .fontSize(16)
            .fontWeight(isChecked ? 
              FontWeight.Bold : FontWeight.Normal)
            .fontColor(isChecked ? '#007DFF' : Color.Black)
          
          Text(this.getCountDescription(count))
            .fontSize(12)
            .fontColor(isChecked ? '#66B3FF' : Color.Gray)
        }
        .layoutWeight(1)
      }
      .padding({ left: 15, right: 15, top: 12, bottom: 12 })
      .borderRadius(8)
      .border({
        width: isChecked ? 2 : 1,
        color: isChecked ? '#007DFF' : '#E5E5E5'
      })
      .backgroundColor(isChecked ? '#F0F8FF' : Color.White)
    }
  }
  
  // 获取图标和描述的辅助方法保持不变
  getPeopleIcon(count: number): Resource { /* 同上 */ }
  getCountDescription(count: number): string { /* 同上 */ }
}

方案二:组件封装 - 可复用的自定义Radio

对于需要在多个地方使用的场景,我们可以封装一个完整的自定义Radio组件:

// 可复用的自定义Radio组件
@Component
export struct CustomizableRadio {
  // 属性参数
  @Prop value: string = '';          // Radio的值
  @Prop label: string = '';          // 显示文本
  @Prop subLabel?: string = '';      // 子文本
  @Prop icon?: Resource;             // 图标
  @Prop checked: boolean = false;    // 选中状态
  @Prop groupName: string = '';      // 组名,用于互斥
  
  // 事件
  private onRadioChange?: (value: string, checked: boolean) => void;
  
  // 控制器,用于外部控制
  private controller: RadioController = new RadioController();
  
  build() {
    // 点击区域
    ClickableRegion({ clicks: 1 })
      .onClick(() => {
        // 只有未选中时才触发
        if (!this.checked) {
          this.handleClick();
        }
      })
      .hitTestBehavior(HitTestMode.Default)
      .borderRadius(8)
    {
      this.renderContent()
    }
  }
  
  @Builder
  renderContent() {
    Row({ space: 12 }) {
      // 左侧:选择指示器
      this.renderIndicator()
      
      // 中间:主要内容
      Column({ space: 4 }) {
        // 主标签
        Text(this.label)
          .fontSize(16)
          .fontWeight(this.checked ? 
            FontWeight.Bold : FontWeight.Normal)
          .fontColor(this.checked ? '#007DFF' : Color.Black)
        
        // 子标签(可选)
        if (this.subLabel) {
          Text(this.subLabel)
            .fontSize(12)
            .fontColor(this.checked ? '#66B3FF' : Color.Gray)
        }
      }
      .layoutWeight(1)
      
      // 右侧:图标(可选)
      if (this.icon) {
        Image(this.icon)
          .width(24)
          .height(24)
          .opacity(this.checked ? 1 : 0.6)
      }
    }
    .padding({ left: 16, right: 16, top: 12, bottom: 12 })
    .borderRadius(8)
    .border({
      width: this.checked ? 2 : 1,
      color: this.checked ? '#007DFF' : '#E5E5E5',
      style: BorderStyle.Solid
    })
    .backgroundColor(this.checked ? '#F0F8FF' : Color.White)
    // 添加点击反馈
    .stateStyles({
      pressed: {
        backgroundColor: this.checked ? '#E0F0FF' : '#F5F5F5'
      }
    })
  }
  
  @Builder
  renderIndicator() {
    if (this.checked) {
      // 选中状态
      Stack({ alignContent: Alignment.Center }) {
        // 外圈
        Circle({ width: 20, height: 20 })
          .fill('#007DFF')
        
        // 内圈白点
        Circle({ width: 8, height: 8 })
          .fill(Color.White)
      }
    } else {
      // 未选中状态
      Circle({ width: 20, height: 20 })
        .stroke({
          width: 1.5,
          color: '#CCCCCC'
        })
        .fill(Color.Transparent)
    }
  }
  
  // 点击处理
  private handleClick(): void {
    // 通知外部选中状态变化
    this.onRadioChange?.(this.value, true);
    
    // 如果有控制器,通过控制器更新
    if (this.controller) {
      this.controller.setChecked(true);
    }
    
    // 触发自定义点击动画
    this.animateSelection();
  }
  
  // 选中动画
  private animateSelection(): void {
    // 可以添加缩放或颜色变化动画
    // 这里简化处理
    console.log(`Radio ${this.value} 被选中`);
  }
  
  // 设置控制器回调
  setOnChange(callback: (value: string, checked: boolean) => void): this {
    this.onRadioChange = callback;
    return this;
  }
  
  // 设置控制器
  setController(controller: RadioController): this {
    this.controller = controller;
    return this;
  }
}

// Radio组控制器
export class RadioGroupController {
  private radios: Map<string, CustomizableRadio> = new Map();
  private currentValue: string = '';
  private onChange?: (value: string) => void;
  
  // 注册Radio
  registerRadio(value: string, radio: CustomizableRadio): void {
    this.radios.set(value, radio);
    
    // 设置Radio的回调
    radio.setOnChange((changedValue: string, checked: boolean) => {
      if (checked) {
        this.selectRadio(changedValue);
      }
    });
  }
  
  // 选择某个Radio
  selectRadio(value: string): void {
    if (this.currentValue === value) {
      return; // 已经是选中状态
    }
    
    // 取消之前选中的
    if (this.currentValue && this.radios.has(this.currentValue)) {
      const previousRadio = this.radios.get(this.currentValue);
      // 这里需要通过控制器设置checked为false
      // 在实际实现中,可能需要更复杂的控制逻辑
    }
    
    // 设置新的选中
    this.currentValue = value;
    
    // 触发回调
    this.onChange?.(value);
    
    console.log(`Radio组选中: ${value}`);
  }
  
  // 设置变化回调
  setOnChange(callback: (value: string) => void): void {
    this.onChange = callback;
  }
  
  // 获取当前选中的值
  getSelectedValue(): string {
    return this.currentValue;
  }
}

方案三:使用Radio的原生能力结合自定义

如果不想完全重写Radio的逻辑,可以尝试另一种方法:在Radio内部使用自定义内容,但保留Radio的原生交互:

@Component
struct HybridRadioSolution {
  @State selectedValue: string = 'option1';
  
  build() {
    Column({ space: 20 }) {
      Text('混合方案:保留Radio原生交互')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
      
      // 使用RadioGroup保持原生单选逻辑
      RadioGroup({ group: 'customGroup' }) {
        // 选项1
        Radio({ value: 'option1', group: 'customGroup' })
          .checked(this.selectedValue === 'option1')
          .onChange((checked: boolean) => {
            if (checked) {
              this.selectedValue = 'option1';
            }
          })
          .width('100%')
          .height(60)
        {
          // 在Radio内部使用自定义布局
          Row({ space: 12 }) {
            // 左侧自定义内容
            Column({ space: 4 }) {
              Text('经济舱')
                .fontSize(16)
                .fontColor(this.selectedValue === 'option1' ? 
                  '#007DFF' : Color.Black)
              
              Text('最优惠的价格')
                .fontSize(12)
                .fontColor(Color.Gray)
            }
            
            // 右侧价格
            Text('¥ 680')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FF6B00')
              .margin({ left: 20 })
          }
          .padding(12)
          .borderRadius(8)
          .border({
            width: this.selectedValue === 'option1' ? 2 : 1,
            color: this.selectedValue === 'option1' ? 
              '#007DFF' : '#E5E5E5'
          })
        }
        
        // 选项2
        Radio({ value: 'option2', group: 'customGroup' })
          .checked(this.selectedValue === 'option2')
          .onChange((checked: boolean) => {
            if (checked) {
              this.selectedValue = 'option2';
            }
          })
          .width('100%')
          .height(60)
        {
          // 类似的内部布局
          Row({ space: 12 }) {
            Column({ space: 4 }) {
              Text('商务舱')
                .fontSize(16)
                .fontColor(this.selectedValue === 'option2' ? 
                  '#007DFF' : Color.Black)
              
              Text('额外的行李额和休息室')
                .fontSize(12)
                .fontColor(Color.Gray)
            }
            
            Text('¥ 1,280')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FF6B00')
              .margin({ left: 20 })
          }
          .padding(12)
          .borderRadius(8)
          .border({
            width: this.selectedValue === 'option2' ? 2 : 1,
            color: this.selectedValue === 'option2' ? 
              '#007DFF' : '#E5E5E5'
          })
        }
      }
      .width('100%')
      
      // 显示当前选择
      Text(`当前选择: ${this.selectedValue === 'option1' ? '经济舱' : '商务舱'}`)
        .fontSize(14)
        .margin({ top: 20 })
    }
    .padding(20)
  }
}

深入原理:为什么ContentModifier会破坏单选功能?

要彻底理解这个问题,我们需要深入鸿蒙UI框架的实现机制。

Radio组件的内部结构

// Radio组件的简化内部实现
class RadioComponent extends Component {
  // 核心属性
  private _value: string;
  private _group: string;
  private _checked: boolean = false;
  
  // 事件监听器
  private _changeListeners: ((checked: boolean) => void)[] = [];
  
  // 组件构建
  build() {
    // 默认的Radio构建
    this.buildDefaultRadio();
  }
  
  buildDefaultRadio() {
    // 1. 外层的Touchable区域
    Touchable({ type: TouchType.Normal })
      .onClick(() => {
        this.handleTap();
      })
    {
      Row({ space: 8 }) {
        // 2. 选择指示器(圆圈)
        this.renderIndicator()
        
        // 3. 文本标签
        if (this._label) {
          Text(this._label)
            .fontSize(this._fontSize)
        }
      }
    }
  }
  
  // 点击处理
  handleTap() {
    if (!this._checked) {
      // 关键步骤:通知RadioGroup
      this.notifyGroup();
      
      // 更新自身状态
      this._checked = true;
      this.updateState();
      
      // 触发事件
      this._changeListeners.forEach(listener => {
        listener(true);
      });
    }
  }
  
  // 通知RadioGroup更新其他Radio
  notifyGroup() {
    const group = RadioGroupManager.getGroup(this._group);
    if (group) {
      group.onRadioSelected(this._value);
    }
  }
}

ContentModifier如何工作

// ContentModifier的简化实现
function applyContentModifier(component: Component, modifier: Function) {
  // 保存原始构建方法
  const originalBuild = component.build;
  
  // 重写构建方法
  component.build = function() {
    // 执行自定义内容构建
    const customContent = modifier.call(this);
    
    // 关键问题:自定义内容可能不包含原始的Touchable包装
    // 也不包含与RadioGroup的通信逻辑
    
    return customContent;
  };
}

问题的根本原因

  1. 事件绑定丢失:默认的Radio有Touchable包装来处理点击事件,但自定义内容可能没有

  2. 组通信中断:Radio与RadioGroup之间的通信机制被破坏

  3. 状态管理分离:checked状态的变化没有通知到同组其他Radio

正确的自定义方式

链接1中提到的解决方案本质上是重新实现Radio的核心逻辑。具体来说:

// 正确解决方案的核心逻辑
class FixedRadioWithCustomContent {
  // 必须自己管理的状态
  private isChecked: boolean = false;
  private groupMembers: FixedRadioWithCustomContent[] = [];
  
  // 点击处理
  onCustomContentClick() {
    if (!this.isChecked) {
      // 1. 取消同组其他Radio的选中
      this.uncheckOtherRadiosInGroup();
      
      // 2. 设置自己为选中
      this.isChecked = true;
      
      // 3. 更新UI
      this.updateCustomAppearance();
      
      // 4. 触发事件
      this.triggerChangeEvent(true);
    }
  }
  
  // 关键:取消同组其他Radio的选中
  uncheckOtherRadiosInGroup() {
    this.groupMembers.forEach(radio => {
      if (radio !== this) {
        radio.setChecked(false);
      }
    });
  }
}

最佳实践:自定义表单组件的设计模式

基于这个问题的解决方案,我们可以总结出自定义表单组件的最佳实践。

模式一:代理模式(Delegate Pattern)

// 代理模式:自定义组件代理原始组件的功能
@Component
struct DelegatedRadio {
  // 代理原始Radio
  private originalRadio: Radio = new Radio();
  
  // 自定义内容
  @Builder customContent: () => void;
  
  // 同步状态
  aboutToUpdate() {
    // 同步checked状态
    this.originalRadio.checked = this.checked;
    
    // 同步其他属性
    this.originalRadio.value = this.value;
    this.originalRadio.group = this.group;
  }
  
  build() {
    // 使用原始Radio,但包装自定义内容
    Column() {
      // 隐藏原始Radio的视觉部分
      this.originalRadio
        .opacity(0)
        .width(0)
        .height(0)
      
      // 显示自定义内容
      Touchable({ type: TouchType.Normal })
        .onClick(() => {
          // 代理点击事件到原始Radio
          this.originalRadio.simulateClick();
        })
      {
        this.customContent()
      }
    }
  }
}

模式二:组合模式(Composite Pattern)

// 组合模式:将多个简单组件组合成复杂组件
@Component
struct CompositeRadioGroup {
  @State selectedIndex: number = 0;
  @State options: RadioOption[] = [];
  
  // 选项接口
  interface RadioOption {
    value: string;
    label: string;
    icon?: Resource;
    description?: string;
  }
  
  build() {
    Column({ space: 8 }) {
      ForEach(this.options, (option: RadioOption, index: number) => {
        this.CompositeRadioItem(option, index)
      })
    }
  }
  
  @Builder
  CompositeRadioItem(option: RadioOption, index: number) {
    const isSelected = this.selectedIndex === index;
    
    Touchable({ type: TouchType.Normal })
      .onClick(() => {
        this.selectedIndex = index;
        this.onOptionSelected?.(option.value);
      })
      .stateEffect(true)
    {
      Row({ space: 12 }) {
        // 选择指示器
        Circle({ width: 20, height: 20 })
          .fill(isSelected ? '#007DFF' : Color.Transparent)
          .stroke({
            width: isSelected ? 0 : 1.5,
            color: isSelected ? '#007DFF' : '#CCCCCC'
          })
        
        // 图标(可选)
        if (option.icon) {
          Image(option.icon)
            .width(24)
            .height(24)
            .opacity(isSelected ? 1 : 0.7)
        }
        
        // 文本内容
        Column({ space: 2 }) {
          Text(option.label)
            .fontSize(16)
            .fontWeight(isSelected ? 
              FontWeight.Bold : FontWeight.Normal)
            .fontColor(isSelected ? '#007DFF' : Color.Black)
          
          if (option.description) {
            Text(option.description)
              .fontSize(12)
              .fontColor(isSelected ? '#66B3FF' : Color.Gray)
          }
        }
        .layoutWeight(1)
      }
      .padding(12)
      .borderRadius(8)
      .border({
        width: isSelected ? 2 : 1,
        color: isSelected ? '#007DFF' : '#E5E5E5'
      })
      .backgroundColor(isSelected ? '#F0F8FF' : Color.White)
    }
  }
}

模式三:高阶组件模式(HOC Pattern)

// 高阶组件:为现有组件添加单选功能
function withRadioGroup<T>(WrappedComponent: ComponentType<T>) {
  @Component
  struct RadioGroupHOC {
    @State selectedValue: string = '';
    @Provide selectedValueInGroup: string = '';
    
    @Consume contextValue: string = '';
    
    build() {
      // 提供上下文给子组件
      Provide({ selectedValueInGroup: this.selectedValue }) {
        Column() {
          // 渲染被包装的组件
          WrappedComponent()
            .onValueChange((newValue: string) => {
              this.selectedValue = newValue;
            })
        }
      }
    }
  }
  
  return RadioGroupHOC;
}

// 使用示例
@Entry
@Component
struct MyForm {
  build() {
    // 使用高阶组件包装
    const EnhancedForm = withRadioGroup(MyCustomForm);
    
    EnhancedForm()
  }
}

实战案例:完整的旅行选项组件

让我们将学到的知识应用到实际场景中,创建一个完整的旅行选项选择组件。

// 完整的旅行选项选择器
@Component
export struct TravelOptionsSelector {
  @State selectedTransport: string = 'plane';
  @State selectedSeason: string = 'spring';
  @State selectedBudget: string = 'mid';
  
  // 交通工具选项
  private transportOptions = [
    { value: 'plane', label: '飞机', icon: '✈️', description: '快速到达' },
    { value: 'train', label: '高铁', icon: '🚄', description: '舒适便捷' },
    { value: 'car', label: '自驾', icon: '🚗', description: '自由灵活' },
    { value: 'bus', label: '大巴', icon: '🚌', description: '经济实惠' }
  ];
  
  // 季节选项
  private seasonOptions = [
    { value: 'spring', label: '春季', icon: '🌸', color: '#FFB6C1' },
    { value: 'summer', label: '夏季', icon: '🌊', color: '#87CEEB' },
    { value: 'autumn', label: '秋季', icon: '🍁', color: '#FFA500' },
    { value: 'winter', label: '冬季', icon: '⛄', color: '#ADD8E6' }
  ];
  
  // 预算选项
  private budgetOptions = [
    { value: 'low', label: '经济型', range: '¥1000-3000', color: '#90EE90' },
    { value: 'mid', label: '舒适型', range: '¥3000-6000', color: '#FFD700' },
    { value: 'high', label: '豪华型', range: '¥6000+', color: '#FFA07A' }
  ];
  
  build() {
    Column({ space: 30 }) {
      // 标题
      Text('定制你的旅行计划')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 20 })
      
      // 1. 交通工具选择
      this.TransportSelector()
      
      // 2. 出行季节选择
      this.SeasonSelector()
      
      // 3. 预算范围选择
      this.BudgetSelector()
      
      // 4. 提交按钮
      Button('生成旅行计划', { type: ButtonType.Capsule })
        .width('90%')
        .height(50)
        .backgroundColor('#007DFF')
        .fontColor(Color.White)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .margin({ top: 20, bottom: 30 })
        .onClick(() => {
          this.generateTravelPlan();
        })
    }
    .width('100%')
    .padding(20)
    .backgroundColor('#F8F9FA')
  }
  
  // 交通工具选择器
  @Builder
  TransportSelector() {
    Column({ space: 12 }) {
      Text('选择交通工具')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#666666')
      
      // 使用Grid布局
      GridRow({ columns: 6, gutter: 10 }) {
        ForEach(this.transportOptions, (option) => {
          GridCol({ span: 3 }) {
            this.TransportOption(option)
          }
        })
      }
    }
  }
  
  @Builder
  TransportOption(option: any) {
    const isSelected = this.selectedTransport === option.value;
    
    Touchable({ type: TouchType.Normal })
      .onClick(() => {
        this.selectedTransport = option.value;
      })
      .stateEffect(true)
    {
      Column({ space: 8 }) {
        // 图标
        Text(option.icon)
          .fontSize(24)
        
        // 标签
        Text(option.label)
          .fontSize(14)
          .fontColor(isSelected ? '#007DFF' : '#333333')
          .fontWeight(isSelected ? FontWeight.Medium : FontWeight.Normal)
        
        // 描述
        Text(option.description)
          .fontSize(10)
          .fontColor(isSelected ? '#66B3FF' : '#666666')
      }
      .width('100%')
      .padding(12)
      .borderRadius(12)
      .border({
        width: isSelected ? 2 : 1,
        color: isSelected ? '#007DFF' : '#E0E0E0',
        style: BorderStyle.Solid
      })
      .backgroundColor(isSelected ? '#F0F8FF' : Color.White)
      .shadow(isSelected ? {
        radius: 8,
        color: '#007DFF22',
        offsetX: 0,
        offsetY: 2
      } : null)
    }
  }
  
  // 季节选择器
  @Builder
  SeasonSelector() {
    Column({ space: 12 }) {
      Text('选择出行季节')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#666666')
      
      GridRow({ columns: 6, gutter: 10 }) {
        ForEach(this.seasonOptions, (option) => {
          GridCol({ span: 3 }) {
            this.SeasonOption(option)
          }
        })
      }
    }
  }
  
  @Builder
  SeasonOption(option: any) {
    const isSelected = this.selectedSeason === option.value;
    
    Touchable({ type: TouchType.Normal })
      .onClick(() => {
        this.selectedSeason = option.value;
      })
    {
      Stack({ alignContent: Alignment.Center }) {
        // 背景
        Circle({ width: 80, height: 80 })
          .fill(isSelected ? option.color + 'CC' : option.color + '33')
        
        Column({ space: 4 }) {
          // 季节图标
          Text(option.icon)
            .fontSize(24)
          
          // 季节名称
          Text(option.label)
            .fontSize(12)
            .fontColor(isSelected ? '#FFFFFF' : '#333333')
            .fontWeight(isSelected ? FontWeight.Medium : FontWeight.Normal)
        }
      }
      .width(80)
      .height(80)
      .opacity(isSelected ? 1 : 0.8)
    }
  }
  
  // 预算选择器
  @Builder
  BudgetSelector() {
    Column({ space: 12 }) {
      Text('选择预算范围')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#666666')
      
      Row({ space: 8 }) {
        ForEach(this.budgetOptions, (option) => {
          this.BudgetOption(option)
        })
      }
    }
  }
  
  @Builder
  BudgetOption(option: any) {
    const isSelected = this.selectedBudget === option.value;
    
    Touchable({ type: TouchType.Normal })
      .onClick(() => {
        this.selectedBudget = option.value;
      })
      .stateEffect(true)
    {
      Column({ space: 6 }) {
        // 预算范围
        Text(option.range)
          .fontSize(12)
          .fontColor(isSelected ? '#FFFFFF' : '#666666')
        
        // 预算类型
        Text(option.label)
          .fontSize(14)
          .fontColor(isSelected ? '#FFFFFF' : '#333333')
          .fontWeight(FontWeight.Medium)
      }
      .padding({ top: 12, bottom: 12, left: 20, right: 20 })
      .borderRadius(20)
      .backgroundColor(isSelected ? option.color : Color.White)
      .border({
        width: 1,
        color: isSelected ? option.color : '#E0E0E0',
        style: BorderStyle.Solid
      })
      .shadow(isSelected ? {
        radius: 4,
        color: option.color + '44',
        offsetX: 0,
        offsetY: 2
      } : null)
    }
  }
  
  // 生成旅行计划
  generateTravelPlan() {
    const plan = {
      交通工具: this.transportOptions.find(t => t.value === this.selectedTransport)?.label,
      出行季节: this.seasonOptions.find(s => s.value === this.selectedSeason)?.label,
      预算范围: this.budgetOptions.find(b => b.value === this.selectedBudget)?.label + 
               ' (' + this.budgetOptions.find(b => b.value === this.selectedBudget)?.range + ')',
      生成时间: new Date().toLocaleString()
    };
    
    console.log('生成的旅行计划:', plan);
    
    // 这里可以触发AI生成旅行攻略
    this.generateAITravelGuide(plan);
  }
  
  // AI生成旅行攻略(示例)
  generateAITravelGuide(plan: any) {
    console.log('正在生成AI旅行攻略...');
    
    // 模拟AI生成
    setTimeout(() => {
      promptAction.showToast({
        message: '旅行攻略生成成功!',
        duration: 2000
      });
    }, 1000);
  }
}

总结与进阶建议

关键要点总结

通过解决Radio自定义内容后的单选失效问题,我们学到了:

  1. 组件定制的边界:当深度定制组件时,可能会破坏其核心功能

  2. 状态管理的完整性:自定义组件需要自己管理状态和事件

  3. 事件传递的连续性:确保用户交互能正确触发状态变化

进阶建议

  1. 测试覆盖率:自定义组件需要更全面的测试

// 自定义Radio的测试用例
describe('CustomRadio Tests', () => {
  it('should select only one option in group', () => {
    const radioGroup = new CustomRadioGroup();
    const radio1 = new CustomRadio({ value: '1' });
    const radio2 = new CustomRadio({ value: '2' });
    
    radioGroup.add(radio1);
    radioGroup.add(radio2);
    
    // 点击第一个
    radio1.click();
    expect(radio1.checked).toBe(true);
    expect(radio2.checked).toBe(false);
    
    // 点击第二个
    radio2.click();
    expect(radio1.checked).toBe(false);
    expect(radio2.checked).toBe(true);
  });
});
  1. 性能优化:避免不必要的重渲染

// 使用@State和@Prop优化性能
@Component
struct OptimizedRadioGroup {
  // 使用数组管理状态,减少重渲染
  @State private selectedIndex: number = 0;
  
  // 使用@Prop传递不可变数据
  @Prop options: string[];
  
  // 使用@Link与父组件通信
  @Link selectedValue: string;
  
  aboutToUpdate() {
    // 避免不必要的更新
    if (this.options.length === 0) {
      return;
    }
  }
}
  1. 无障碍支持:确保自定义组件可访问

// 添加无障碍支持
@Component
struct AccessibleRadio {
  // 添加无障碍标签
  @Prop accessibilityLabel: string = '';
  // 添加无障碍提示
  @Prop accessibilityHint: string = '';
  // 无障碍角色
  @Prop accessibilityRole: string = 'radio';
  
  build() {
    Touchable({ type: TouchType.Normal })
      .accessibilityLabel(this.accessibilityLabel)
      .accessibilityHint(this.accessibilityHint)
      .accessibilityRole(this.accessibilityRole)
      .accessibilityState({ 
        checked: this.checked,
        disabled: this.disabled 
      })
    {
      // 自定义内容
    }
  }
}

最终建议

在鸿蒙应用开发中,自定义组件是一把双刃剑。它提供了极大的灵活性,但也带来了维护成本。在决定自定义组件前,先问自己几个问题:

  1. 真的需要完全自定义吗?能否通过样式调整实现?

  2. 自定义后,是否还能保持组件的核心功能?

  3. 是否有现成的第三方组件库可以使用?

  4. 自定义组件的测试和维护成本是多少?

记住,最好的自定义是在保持组件核心功能的前提下,最小化地改变其外观和行为。当必须深度定制时,确保你完全理解原组件的工作机制,并准备好自己管理所有的状态和交互逻辑。

通过本文的解决方案,你现在应该能够 confidently 处理Radio组件的自定义需求,同时保持其单选功能。这不仅适用于Radio,也适用于其他需要保持特定交互模式的组件。在鸿蒙6的开发旅程中,愿你的组件既能美丽动人,又能功能完整!

Logo

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

更多推荐