105.[HarmonyOS NEXT 实战案例:新闻阅读应用] 高级篇 - 高级布局技巧与组件封装

[HarmonyOS NEXT 实战案例:新闻阅读应用] 高级篇 - 高级布局技巧与组件封装

项目已开源,开源地址: gitcode.com/nutpi/Harmo... , 欢迎fork & star

效果演示

引言

在前两篇教程中,我们学习了如何使用HarmonyOS NEXT的RowSplit组件构建新闻阅读应用的基本布局,以及如何添加交互功能和状态管理。在本篇教程中,我们将进一步探讨高级布局技巧和组件封装,包括自适应布局、自定义组件、高级状态管理等,使应用更加灵活、可维护和专业。

高级布局技巧

自适应布局

在不同屏幕尺寸和方向下,应用界面应该能够自适应调整。我们可以使用以下技巧实现自适应布局:

1. 媒体查询

使用mediaquery模块可以根据屏幕尺寸和方向调整布局:

typescript 复制代码
import mediaquery from '@ohos.mediaquery';

@Component
export struct NewsReaderExample {
  @State isLandscape: boolean = false;
  private landscapeListener: mediaquery.MediaQueryListener | null = null;

  aboutToAppear() {
    // 创建媒体查询监听器
    let mediaQueryList = mediaquery.matchMediaSync('(orientation: landscape)');
    this.landscapeListener = mediaQueryList.on('change', (mediaQueryResult) => {
      this.isLandscape = mediaQueryResult.matches;
    });
  }

  aboutToDisappear() {
    // 移除媒体查询监听器
    if (this.landscapeListener) {
      this.landscapeListener.off('change');
      this.landscapeListener = null;
    }
  }

  build() {
    Column() {
      // 标题行
      
      // 根据屏幕方向调整布局
      if (this.isLandscape) {
        // 横屏布局
        this.buildLandscapeLayout();
      } else {
        // 竖屏布局
        this.buildPortraitLayout();
      }
    }
    .width('100%')
    .padding(15)
  }

  @Builder
  private buildLandscapeLayout() {
    // 横屏布局实现
    RowSplit() {
      // 左侧新闻分类区域
      Column() {
        // 新闻分类内容
      }
      .width('20%')
      .backgroundColor('#f5f5f5')

      // 中间新闻列表区域
      Column() {
        // 搜索框和新闻列表
      }
      .width('40%')

      // 右侧新闻详情区域
      Column() {
        // 新闻详情内容
      }
      .width('40%')
    }
    .height(600)
  }

  @Builder
  private buildPortraitLayout() {
    // 竖屏布局实现
    if (!this.isDetailMode) {
      RowSplit() {
        // 左侧新闻分类区域
        Column() {
          // 新闻分类内容
        }
        .width('25%')
        .backgroundColor('#f5f5f5')

        // 右侧新闻列表区域
        Column() {
          // 搜索框和新闻列表
        }
        .width('75%')
      }
      .height(600)
    } else {
      // 新闻详情页
      this.NewsDetailComponent(this.selectedNews!)
    }
  }
}

在这个实现中,我们使用媒体查询监听屏幕方向的变化,并根据屏幕方向显示不同的布局:

  • 在横屏模式下,使用三栏布局,同时显示分类、列表和详情
  • 在竖屏模式下,使用两栏布局,根据状态切换列表和详情
2. 百分比和弹性布局

使用百分比和弹性布局可以使界面元素根据可用空间自动调整大小:

typescript 复制代码
Row() {
  // 左侧区域,固定宽度
  Column() {
    // 内容
  }
  .width(100)

  // 中间区域,弹性宽度
  Column() {
    // 内容
  }
  .layoutWeight(1)

  // 右侧区域,固定宽度
  Column() {
    // 内容
  }
  .width(100)
}
.width('100%')

在这个例子中,中间区域使用layoutWeight属性,会自动占据除左右区域外的所有可用空间。

3. 栅格布局

使用栅格布局可以更精细地控制界面元素的布局:

typescript 复制代码
GridRow() {
  // 新闻分类区域,占3列
  GridCol(3) {
    // 内容
  }

  // 新闻列表区域,占9列
  GridCol(9) {
    // 内容
  }
}
.width('100%')
.gutter(10) // 列间距

在这个例子中,我们使用12列栅格系统,新闻分类区域占3列,新闻列表区域占9列。

高级动画效果

添加动画效果可以使界面更加生动和专业:

1. 页面切换动画
typescript 复制代码
// 页面切换动画
if (!this.isDetailMode) {
  // 新闻列表
  RowSplit() {
    // 内容
  }
  .transition({ type: TransitionType.All, opacity: 0.0, scale: { x: 0.9, y: 0.9 } })
  .animation({
    duration: 300,
    curve: Curve.EaseOut,
    delay: 0,
    iterations: 1,
    playMode: PlayMode.Normal
  })
} else {
  // 新闻详情
  this.NewsDetailComponent(this.selectedNews!)
  .transition({ type: TransitionType.All, opacity: 0.0, scale: { x: 0.9, y: 0.9 } })
  .animation({
    duration: 300,
    curve: Curve.EaseOut,
    delay: 0,
    iterations: 1,
    playMode: PlayMode.Normal
  })
}

在这个例子中,我们为页面切换添加了淡入淡出和缩放动画,使切换过程更加平滑。

2. 列表项动画
typescript 复制代码
List() {
  ForEach(this.getFilteredNews(), (item: NewsItem, index: number) => {
    ListItem() {
      this.NewsItemComponent(item)
    }
    .padding(10)
    .onClick(() => {
      this.selectedNews = item;
      this.isDetailMode = true;
    })
    .transition({ type: TransitionType.All, opacity: 0.0, translate: { x: 50, y: 0 } })
    .animation({
      duration: 300,
      curve: Curve.EaseOut,
      delay: 50 * index, // 延迟时间与索引相关,实现错落有致的动画效果
      iterations: 1,
      playMode: PlayMode.Normal
    })
  })
}

在这个例子中,我们为列表项添加了淡入和平移动画,并根据索引设置不同的延迟时间,实现错落有致的动画效果。

组件封装

随着应用复杂度的增加,将界面拆分为多个可复用的组件变得非常重要。下面我们将新闻阅读应用拆分为多个自定义组件:

1. 新闻分类组件

typescript 复制代码
@Component
struct NewsCategoryPanel {
  @Link selectedCategory: string;
  private categories: string[];
  private onCategorySelected: (category: string) => void;

  build() {
    Column() {
      Button('我的收藏')
        .width('90%')
        .height(50)
        .fontSize(16)
        .margin({ top: 10, bottom: 10 })
        .borderRadius(8)
        .backgroundColor('#ff9500')
        .fontColor('#ffffff')
        .onClick(() => {
          this.onCategorySelected('收藏');
        })
      
      ForEach(this.categories, (category: string) => {
        Button(category)
          .width('90%')
          .height(50)
          .fontSize(16)
          .margin({ top: 10 })
          .borderRadius(8)
          .backgroundColor(this.selectedCategory === category ? '#007DFF' : '#ffffff')
          .fontColor(this.selectedCategory === category ? '#ffffff' : '#333333')
          .onClick(() => {
            this.onCategorySelected(category);
          })
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f5f5')
    .padding({ top: 10 })
  }
}

2. 新闻列表组件

typescript 复制代码
@Component
struct NewsListPanel {
  @Link searchText: string;
  @Link favoriteNews: Set<string>;
  private newsData: NewsItem[];
  private selectedCategory: string;
  private onNewsSelected: (news: NewsItem) => void;
  private onFavoriteToggle: (title: string, isFavorite: boolean) => void;

  build() {
    Column() {
      // 搜索框
      Row() {
        TextInput({ placeholder: '搜索新闻', text: this.searchText })
          .width('80%')
          .height(40)
          .backgroundColor('#f0f0f0')
          .borderRadius(20)
          .padding({ left: 15, right: 15 })
          .onChange((value: string) => {
            this.searchText = value;
          })
        
        Button('搜索')
          .width('18%')
          .height(40)
          .fontSize(14)
          .margin({ left: '2%' })
          .borderRadius(20)
          .backgroundColor('#007DFF')
          .onClick(() => {
            // 搜索逻辑
            console.info(`搜索:${this.searchText}`);
          })
      }
      .width('100%')
      .padding({ left: 10, right: 10, top: 10, bottom: 10 })

      // 新闻列表
      List() {
        ForEach(this.getFilteredNews(), (item: NewsItem) => {
          ListItem() {
            this.NewsItemComponent(item)
          }
          .padding(10)
          .onClick(() => {
            this.onNewsSelected(item);
          })
        })
      }
      .width('100%')
      .height('100%')
      .divider({ strokeWidth: 1, color: '#f0f0f0', startMargin: 10, endMargin: 10 })
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  private NewsItemComponent(item: NewsItem) {
    Row() {
      Column() {
        Text(item.title)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 5 })
        Row() {
          Text(item.source)
            .fontSize(14)
            .fontColor('#666')
          Text(item.time)
            .fontSize(14)
            .fontColor('#666')
            .margin({ left: 10 })
          
          Blank()
          
          Button(this.favoriteNews.has(item.title) ? '已收藏' : '收藏')
            .fontSize(12)
            .height(24)
            .backgroundColor(this.favoriteNews.has(item.title) ? '#ff9500' : '#f0f0f0')
            .fontColor(this.favoriteNews.has(item.title) ? '#ffffff' : '#333333')
            .borderRadius(12)
            .onClick((event: ClickEvent) => {
              event.stopPropagation();
              this.onFavoriteToggle(item.title, !this.favoriteNews.has(item.title));
            })
        }
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)

      Image(item.imageUrl)
        .width(100)
        .height(70)
        .objectFit(ImageFit.Cover)
        .borderRadius(5)
        .margin({ left: 10 })
    }
    .width('100%')
  }

  private getFilteredNews(): NewsItem[] {
    // 根据选中的分类和搜索文本过滤新闻
    let filteredNews = this.newsData;
    
    // 根据分类过滤
    if (this.selectedCategory !== '推荐') {
      if (this.selectedCategory === '收藏') {
        // 显示收藏的新闻
        filteredNews = filteredNews.filter(item => this.favoriteNews.has(item.title));
      } else {
        // 显示特定分类的新闻
        filteredNews = filteredNews.filter(item => item.category === this.selectedCategory);
      }
    }
    
    // 根据搜索文本过滤
    if (this.searchText.trim() !== '') {
      const searchLower = this.searchText.toLowerCase();
      filteredNews = filteredNews.filter(item => 
        item.title.toLowerCase().includes(searchLower) || 
        item.source.toLowerCase().includes(searchLower) ||
        item.category.toLowerCase().includes(searchLower)
      );
    }
    
    return filteredNews;
  }
}

3. 新闻详情组件

typescript 复制代码
@Component
struct NewsDetailPanel {
  @Link favoriteNews: Set<string>;
  private newsItem: NewsItem;
  private newsData: NewsItem[];
  private onRelatedNewsSelected: (news: NewsItem) => void;
  private onFavoriteToggle: (title: string, isFavorite: boolean) => void;

  build() {
    Column() {
      // 新闻标题
      Text(this.newsItem.title)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 15 })
      
      // 新闻来源和时间
      Row() {
        Text(this.newsItem.source)
          .fontSize(14)
          .fontColor('#666')
        Text(this.newsItem.time)
          .fontSize(14)
          .fontColor('#666')
          .margin({ left: 10 })
        
        Blank()
        
        Button(this.favoriteNews.has(this.newsItem.title) ? '已收藏' : '收藏')
          .fontSize(14)
          .height(32)
          .backgroundColor(this.favoriteNews.has(this.newsItem.title) ? '#ff9500' : '#f0f0f0')
          .fontColor(this.favoriteNews.has(this.newsItem.title) ? '#ffffff' : '#333333')
          .borderRadius(16)
          .onClick(() => {
            this.onFavoriteToggle(this.newsItem.title, !this.favoriteNews.has(this.newsItem.title));
          })
      }
      .width('100%')
      .margin({ bottom: 20 })
      
      // 新闻图片
      Image(this.newsItem.imageUrl)
        .width('100%')
        .height(200)
        .objectFit(ImageFit.Cover)
        .borderRadius(8)
        .margin({ bottom: 20 })
      
      // 新闻内容
      Text(this.generateNewsContent(this.newsItem))
        .fontSize(16)
        .lineHeight(24)
        .margin({ bottom: 20 })
      
      // 相关新闻
      Text('相关新闻')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 10 })
      
      List() {
        ForEach(this.getRelatedNews(), (relatedItem: NewsItem) => {
          ListItem() {
            Row() {
              Text(relatedItem.title)
                .fontSize(14)
                .layoutWeight(1)
              
              Text(relatedItem.time)
                .fontSize(12)
                .fontColor('#666')
            }
            .width('100%')
            .padding({ top: 8, bottom: 8 })
          }
          .onClick(() => {
            this.onRelatedNewsSelected(relatedItem);
          })
        })
      }
      .width('100%')
      .height(150)
      .divider({ strokeWidth: 1, color: '#f0f0f0' })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .padding(15)
  }

  private getRelatedNews(): NewsItem[] {
    // 获取与当前新闻相关的新闻(同一分类的其他新闻)
    return this.newsData
      .filter(item => item.category === this.newsItem.category && item.title !== this.newsItem.title)
      .slice(0, 3); // 最多显示3条相关新闻
  }

  private generateNewsContent(item: NewsItem): string {
    // 生成新闻内容(实际应用中应该从后端获取)
    return `这是一篇关于${item.category}的新闻。${item.title}。这里是新闻的详细内容,包含了事件的起因、经过和结果。\n\n这是第二段落,提供了更多的背景信息和相关数据。根据最新的统计数据显示,这一领域的发展趋势非常明显。\n\n这是第三段落,包含了专家的观点和分析。多位专家认为,这一事件将对行业产生深远的影响。`;
  }
}

4. 主组件

typescript 复制代码
@Component
export struct NewsReaderExample {
  @State selectedCategory: string = '推荐';
  @State searchText: string = '';
  @State favoriteNews: Set<string> = new Set<string>();
  @State selectedNews: NewsItem | null = null;
  @State isDetailMode: boolean = false;
  @State isLandscape: boolean = false;
  
  private categories: string[] = ['推荐', '科技', '体育', '财经', '娱乐', '健康'];
  @State newsData: NewsItem[] = [
    // 新闻数据
  ];
  private landscapeListener: mediaquery.MediaQueryListener | null = null;

  aboutToAppear() {
    // 创建媒体查询监听器
    let mediaQueryList = mediaquery.matchMediaSync('(orientation: landscape)');
    this.landscapeListener = mediaQueryList.on('change', (mediaQueryResult) => {
      this.isLandscape = mediaQueryResult.matches;
    });
  }

  aboutToDisappear() {
    // 移除媒体查询监听器
    if (this.landscapeListener) {
      this.landscapeListener.off('change');
      this.landscapeListener = null;
    }
  }

  build() {
    Column() {
      Row() {
        Text('新闻阅读应用布局')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
        
        Blank()
        
        if (this.isDetailMode && !this.isLandscape) {
          Button('返回列表')
            .fontSize(14)
            .height(32)
            .backgroundColor('#007DFF')
            .onClick(() => {
              this.isDetailMode = false;
              this.selectedNews = null;
            })
        }
      }
      .width('100%')
      .margin({ bottom: 10 })

      // 根据屏幕方向调整布局
      if (this.isLandscape) {
        // 横屏布局
        this.buildLandscapeLayout();
      } else {
        // 竖屏布局
        this.buildPortraitLayout();
      }
    }
    .width('100%')
    .padding(15)
  }

  @Builder
  private buildLandscapeLayout() {
    // 横屏布局实现
    RowSplit() {
      // 左侧新闻分类区域
      NewsCategoryPanel({
        selectedCategory: $selectedCategory,
        categories: this.categories,
        onCategorySelected: (category: string) => {
          this.selectedCategory = category;
        }
      })
      .width('20%')

      // 中间新闻列表区域
      NewsListPanel({
        searchText: $searchText,
        favoriteNews: $favoriteNews,
        newsData: this.newsData,
        selectedCategory: this.selectedCategory,
        onNewsSelected: (news: NewsItem) => {
          this.selectedNews = news;
          if (!this.isLandscape) {
            this.isDetailMode = true;
          }
        },
        onFavoriteToggle: (title: string, isFavorite: boolean) => {
          if (isFavorite) {
            this.favoriteNews.add(title);
          } else {
            this.favoriteNews.delete(title);
          }
          // 强制更新Set
          this.favoriteNews = new Set(this.favoriteNews);
        }
      })
      .width('40%')

      // 右侧新闻详情区域
      if (this.selectedNews) {
        NewsDetailPanel({
          favoriteNews: $favoriteNews,
          newsItem: this.selectedNews,
          newsData: this.newsData,
          onRelatedNewsSelected: (news: NewsItem) => {
            this.selectedNews = news;
          },
          onFavoriteToggle: (title: string, isFavorite: boolean) => {
            if (isFavorite) {
              this.favoriteNews.add(title);
            } else {
              this.favoriteNews.delete(title);
            }
            // 强制更新Set
            this.favoriteNews = new Set(this.favoriteNews);
          }
        })
        .width('40%')
      } else {
        Column() {
          Text('请选择一条新闻查看详情')
            .fontSize(16)
            .fontColor('#999')
        }
        .width('40%')
        .justifyContent(FlexAlign.Center)
      }
    }
    .height(600)
  }

  @Builder
  private buildPortraitLayout() {
    // 竖屏布局实现
    if (!this.isDetailMode) {
      RowSplit() {
        // 左侧新闻分类区域
        NewsCategoryPanel({
          selectedCategory: $selectedCategory,
          categories: this.categories,
          onCategorySelected: (category: string) => {
            this.selectedCategory = category;
          }
        })
        .width('25%')

        // 右侧新闻列表区域
        NewsListPanel({
          searchText: $searchText,
          favoriteNews: $favoriteNews,
          newsData: this.newsData,
          selectedCategory: this.selectedCategory,
          onNewsSelected: (news: NewsItem) => {
            this.selectedNews = news;
            this.isDetailMode = true;
          },
          onFavoriteToggle: (title: string, isFavorite: boolean) => {
            if (isFavorite) {
              this.favoriteNews.add(title);
            } else {
              this.favoriteNews.delete(title);
            }
            // 强制更新Set
            this.favoriteNews = new Set(this.favoriteNews);
          }
        })
        .width('75%')
      }
      .height(600)
    } else {
      // 新闻详情页
      NewsDetailPanel({
        favoriteNews: $favoriteNews,
        newsItem: this.selectedNews!,
        newsData: this.newsData,
        onRelatedNewsSelected: (news: NewsItem) => {
          this.selectedNews = news;
        },
        onFavoriteToggle: (title: string, isFavorite: boolean) => {
          if (isFavorite) {
            this.favoriteNews.add(title);
          } else {
            this.favoriteNews.delete(title);
          }
          // 强制更新Set
          this.favoriteNews = new Set(this.favoriteNews);
        }
      })
    }
  }
}

在这个实现中,我们将新闻阅读应用拆分为三个主要组件:

  1. NewsCategoryPanel:负责显示新闻分类
  2. NewsListPanel:负责显示新闻列表和搜索框
  3. NewsDetailPanel:负责显示新闻详情

主组件NewsReaderExample负责协调这些子组件,管理状态,并根据屏幕方向调整布局。

高级状态管理

随着应用复杂度的增加,简单的@State状态管理可能不足以满足需求。HarmonyOS NEXT提供了更高级的状态管理机制:

1. @Provide/@Consume

@Provide@Consume装饰器可以实现跨组件的状态共享,避免通过属性层层传递:

typescript 复制代码
// 在父组件中提供状态
@Component
export struct NewsReaderExample {
  @Provide('favoriteNews') favoriteNews: Set<string> = new Set<string>();
  // 其他代码
}

// 在子组件中消费状态
@Component
struct NewsItemComponent {
  @Consume('favoriteNews') favoriteNews: Set<string>;
  private item: NewsItem;
  
  build() {
    // 使用favoriteNews状态
    Button(this.favoriteNews.has(this.item.title) ? '已收藏' : '收藏')
      // 按钮属性
  }
}

2. @Watch

@Watch装饰器可以监听状态变化,执行相应的操作:

typescript 复制代码
@Component
export struct NewsReaderExample {
  @State @Watch('onCategoryChanged') selectedCategory: string = '推荐';
  // 其他代码
  
  onCategoryChanged(newValue: string, oldValue: string) {
    console.info(`分类从${oldValue}变为${newValue}`);
    // 执行其他操作
  }
}

@StorageLink装饰器可以将状态存储在应用级别的存储中,实现跨页面的状态共享:

typescript 复制代码
// 创建应用级别的存储
let storage = new LocalStorage();

// 在页面中使用存储
@Entry(storage)
@Component
export struct NewsReaderExample {
  @StorageLink('favoriteNews') favoriteNews: Set<string> = new Set<string>();
  // 其他代码
}

高级交互功能

1. 下拉刷新

typescript 复制代码
Refresh({ refreshing: this.isRefreshing, offset: 120, friction: 100 }) {
  List() {
    // 新闻列表
  }
  .width('100%')
  .height('100%')
}
.onRefreshing(() => {
  // 刷新逻辑
  setTimeout(() => {
    // 模拟网络请求
    this.isRefreshing = false;
  }, 1000);
})

2. 手势操作

typescript 复制代码
ListItem() {
  this.NewsItemComponent(item)
}
.padding(10)
.gesture(
  PanGesture({ direction: PanDirection.Left })
    .onAction((event: GestureEvent) => {
      // 左滑操作,例如显示删除按钮
    })
)

3. 拖放操作

typescript 复制代码
ListItem() {
  this.NewsItemComponent(item)
}
.padding(10)
.draggable(true)
.onDragStart(() => {
  // 开始拖动
  return this.createDragItemInfo(item);
})

总结

在本教程中,我们学习了如何使用高级布局技巧和组件封装来构建更加灵活、可维护和专业的新闻阅读应用。

相关推荐
别说我什么都不会26 分钟前
【OpenHarmony】 鸿蒙 UI动画开发之recyclerview_animators
harmonyos
勿念4363 小时前
在鸿蒙HarmonyOS 5中使用DevEco Studio实现指南针功能
华为·harmonyos
在人间耕耘4 小时前
unipp---HarmonyOS 应用开发实战
华为·harmonyos
libo_20254 小时前
语音交互设计:为HarmonyOS5应用添加多模态控制方案
harmonyos
kangyouwei4 小时前
鸿蒙开发:18-hilogtool命令的使用
前端·harmonyos
zhanshuo5 小时前
弱网也不怕!鸿蒙分布式数据同步的4大“抗摔“秘籍,购物车实战解析
harmonyos
勿念4365 小时前
在鸿蒙HarmonyOS 5中使用DevEco Studio实现企业微信功能
harmonyos
Georgewu14 小时前
【HarmonyOS 5】鸿蒙中Stage模型与FA模型详解
harmonyos