系列: HarmonyOS 本地工具 App 实战 · 第 2 篇

上一篇: HarmonyOS 天气 App 实战(上):项目搭建与天气主页

注意:本文介绍的 Notification Kit 通知推送、Settings 设置页、城市切换 Swiper 轮播、animateTo 页面动画等功能属于设计模式与参考实现,是对 Part 1 已完成的天气主页的扩展方案。当前项目聚焦于 Part 1 实现的核心天气展示功能(QWeather API 天气查询、7 天预报、定位获取),本文中的代码示例和架构模式均为可落地的扩展方案,尚未集成到当前工程中。读者可参照本文逐步将这些功能接入自己的项目。


引言

上一篇我们完成了天气 App 的主页搭建,能够实时显示当前天气和 7 天预报。但一个好用的天气应用还需要更多功能:当气象台发布暴雨、高温预警时,用户能否第一时间收到通知?切换城市查看不同地区的天气是否方便?温度单位能否在摄氏度和华氏度之间自由切换?

本文将为天气 App 补齐这些关键功能。我们将使用 Notification Kit 实现天气预警通知推送,使用 Preferences 持久化用户设置,通过 animateTo 实现页面切换动画,并用 Swiper 和 Toggle/Select 组件打造流畅的交互体验。

通过本文你将学到:

  • 如何使用 Notification Kit 发布本地通知并创建通知渠道
  • 如何使用 Preferences 持久化存储用户设置(温度单位、通知开关等)
  • 如何使用 animateTo 实现组件的平滑过渡动画
  • 如何使用 Swiper 实现城市卡片轮播切换
  • 如何使用 Toggle 和 Select 组件构建设置页面

天气模块真机运行基线

实现边界:当前工程已完成 QWeather API 天气查询与 7 天预报展示,在真机上可正常获取实时天气数据。本篇描述的通知渠道(Notification Kit)、设置页(Preferences 持久化)、城市轮播(Swiper)和页面动画(animateTo)均为扩展模式,属于设计参考实现,尚未集成到当前工程。读者可在天气主页功能稳定后,参照本文逐步接入这些扩展功能。


环境准备

项目 版本
DevEco Studio 6.1.1 Release
API Level API 24
设备 真机或模拟器

依赖配置

本篇新增 Notification Kit 和 ArkData(Preferences)两个系统 Kit,均通过 @kit.* 导入,无需额外安装:

// oh-package.json5
{
  "name": "weather_app",
  "version": "1.0.0",
  "description": "天气查询应用",
  "main": "",
  "author": "",
  "license": "Apache-2.0",
  "dependencies": {},
  "devDependencies": {}
}

权限配置

天气预警通知需要通知权限。从 API 23 开始,应用发布本地通知前需要先创建通知渠道(NotificationSlot),但无需申请额外权限:

// module.json5
{
  "module": {
    "name": "entry",
    "type": "entry",
    "requestPermissions": [
      {
        "name": "ohos.permission.APPROXIMATELY_LOCATION",
        "reason": "获取当前位置以查询当地天气",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "always"
        }
      }
    ]
  }
}

说明:Notification Kit 发布本地通知不需要特殊权限,但需要先通过 addSlot 创建通知渠道。系统会自动向用户展示通知授权弹窗。


核心实现

Step 1: 定义设置模型与 Preferences 持久化

目标: 使用 @ObservedV2 + @Trace 定义应用设置模型,并通过 Preferences 实现设置数据的持久化存储。

在上一篇的基础上,我们新增一个 SettingsModel 类来管理所有用户设置:

// model/SettingsModel.ets

import { AppStorageV2 } from '@kit.ArkUI';

/** 温度单位 */
export enum TemperatureUnit {
  CELSIUS = 'celsius',
  FAHRENHEIT = 'fahrenheit'
}

/** 应用设置模型 */
@ObservedV2
export class SettingsModel {
  @Trace temperatureUnit: TemperatureUnit = TemperatureUnit.CELSIUS;
  @Trace notificationEnabled: boolean = true;
  @Trace autoRefresh: boolean = true;
  @Trace currentCityIndex: number = 0;
  @Trace isDarkMode: boolean = false;
}

/** 获取全局设置单例 */
export function getSettings(): SettingsModel {
  return AppStorageV2.connect(SettingsModel, 'appSettings', () => new SettingsModel())!;
}

@ObservedV2@Trace 是 HarmonyOS 状态管理 V2 的核心装饰器。与 V1 的 @State 不同,@Trace 能够深度监听对象属性的变化——即使属性是嵌套对象或数组,修改其内部成员也能触发 UI 刷新。

接下来实现 Preferences 持久化服务:

// service/SettingsService.ets

import { dataPreferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { common } from '@kit.AbilityKit';
import { SettingsModel, TemperatureUnit } from '../model/SettingsModel';

const TAG = 'SettingsService';
const PREFS_NAME = 'weather_settings';

/** 设置持久化服务 */
export class SettingsService {
  private prefs: dataPreferences.Preferences | undefined = undefined;

  /** 初始化 Preferences 实例 */
  async init(context: common.UIAbilityContext): Promise<void> {
    this.prefs = await dataPreferences.getPreferences(context, {
      name: PREFS_NAME
    });
    hilog.info(0x0000, TAG, 'Preferences 初始化成功');
  }

  /** 从磁盘加载设置到模型 */
  async loadSettings(settings: SettingsModel): Promise<void> {
    if (!this.prefs) {
      return;
    }
    settings.temperatureUnit = (await this.prefs.get(
      'temperatureUnit', TemperatureUnit.CELSIUS
    )) as TemperatureUnit;
    settings.notificationEnabled = (await this.prefs.get(
      'notificationEnabled', true
    )) as boolean;
    settings.autoRefresh = (await this.prefs.get(
      'autoRefresh', true
    )) as boolean;
    settings.currentCityIndex = (await this.prefs.get(
      'currentCityIndex', 0
    )) as number;
    settings.isDarkMode = (await this.prefs.get(
      'isDarkMode', false
    )) as boolean;
  }

  /** 保存单个设置项 */
  async saveSetting(key: string, value: string | number | boolean): Promise<void> {
    if (!this.prefs) {
      return;
    }
    await this.prefs.put(key, value);
    await this.prefs.flush();
    hilog.info(0x0000, TAG, `设置已保存: ${key} = ${value}`);
  }
}

关键点说明:

  • getPreferences 是异步方法,返回的 Preferences 实例会自动在内存中缓存数据,后续的 get 操作直接读取缓存,性能很高。
  • 每次 put 后调用 flush 将数据写入磁盘,确保应用异常退出时不丢失设置。
  • Preferences 支持 numberstringboolean 等基本类型,不适合存储大量数据。

Step 2: 实现设置页面

目标: 使用 Toggle 和 Select 组件构建设置页面,支持温度单位切换、通知开关、自动刷新等功能。

// pages/SettingsPage.ets

import { hilog } from '@kit.PerformanceAnalysisKit';
import { SettingsModel, TemperatureUnit, getSettings } from '../model/SettingsModel';
import { SettingsService } from '../service/SettingsService';

@Entry
@Component
struct SettingsPage {
  @ObjectLink settings: SettingsModel = getSettings();
  private settingsService: SettingsService = new SettingsService();

  aboutToAppear(): void {
    this.settingsService.init(getContext(this));
  }

  build() {
    Column() {
      // 顶部标题栏
      Row() {
        Text('设置')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 16, bottom: 12 })

      // 设置列表
      List({ space: 0 }) {
        // 温度单位选择
        ListItem() {
          this.SettingItemBuilder(
            '温度单位',
            this.settings.temperatureUnit === TemperatureUnit.CELSIUS ? '摄氏度 (°C)' : '华氏度 (°F)',
            'select'
          )
        }

        // 天气通知开关
        ListItem() {
          this.SettingItemBuilder(
            '天气预警通知',
            '',
            'toggle',
            this.settings.notificationEnabled,
            async (value: boolean) => {
              this.settings.notificationEnabled = value;
              await this.settingsService.saveSetting('notificationEnabled', value);
            }
          )
        }

        // 自动刷新开关
        ListItem() {
          this.SettingItemBuilder(
            '自动刷新天气',
            '',
            'toggle',
            this.settings.autoRefresh,
            async (value: boolean) => {
              this.settings.autoRefresh = value;
              await this.settingsService.saveSetting('autoRefresh', value);
            }
          )
        }

        // 深色模式开关
        ListItem() {
          this.SettingItemBuilder(
            '深色模式',
            '',
            'toggle',
            this.settings.isDarkMode,
            async (value: boolean) => {
              this.settings.isDarkMode = value;
              await this.settingsService.saveSetting('isDarkMode', value);
            }
          )
        }
      }
      .width('100%')
      .layoutWeight(1)
      .padding({ left: 16, right: 16 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  /** 通用设置项构建器 */
  @Builder
  SettingItemBuilder(
    title: string,
    subtitle: string,
    controlType: string,
    toggleValue?: boolean,
    onToggleChange?: (value: boolean) => void
  ) {
    Row() {
      // 左侧文字
      Column({ space: 4 }) {
        Text(title)
          .fontSize(16)
          .fontColor('#333333')
        if (subtitle) {
          Text(subtitle)
            .fontSize(13)
            .fontColor('#999999')
        }
      }
      .alignItems(HorizontalAlign.Start)

      Blank()

      // 右侧控件
      if (controlType === 'select') {
        Select([
          { value: '摄氏度 (°C)', icon: '' },
          { value: '华氏度 (°F)', icon: '' }
        ])
          .selected(this.settings.temperatureUnit === TemperatureUnit.CELSIUS ? 0 : 1)
          .value(this.settings.temperatureUnit === TemperatureUnit.CELSIUS ? '摄氏度' : '华氏度')
          .onSelect(async (index: number) => {
            const unit = index === 0
              ? TemperatureUnit.CELSIUS
              : TemperatureUnit.FAHRENHEIT;
            this.settings.temperatureUnit = unit;
            await this.settingsService.saveSetting('temperatureUnit', unit);
          })
          .width(120)
      } else if (controlType === 'toggle' && toggleValue !== undefined) {
        Toggle({ type: ToggleType.Switch, isOn: toggleValue })
          .onChange(async (isOn: boolean) => {
            if (onToggleChange) {
              onToggleChange(isOn);
            }
          })
          .selectedColor('#4A90D9')
      }
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(12)
    .margin({ bottom: 1 })
  }
}

实现效果:设置页面包含温度单位选择(Select 下拉框)、天气预警通知开关(Toggle)、自动刷新开关、深色模式开关四个设置项,每项左侧为标题和副标题,右侧为对应控件。

这段代码有几个设计要点:

@ObjectLink 装饰器:配合 @ObservedV2 使用,用于接收从父组件传入的可观察对象。与 @State 不同,@ObjectLink 不拥有对象本身,而是引用父组件传递的同一个实例。当 SettingsModel 的任何 @Trace 属性变化时,使用 @ObjectLink 接收的组件会自动刷新。

Select 组件:用于温度单位选择。selected 属性绑定当前选中项索引,onSelect 回调在用户选择新选项时触发。注意 value 属性设置的是 Select 控件上显示的文字。

Toggle 组件:开关控件,type: ToggleType.Switch 表示滑动开关样式。isOn 绑定开关状态,onChange 在用户切换时触发。selectedColor 设置开启时的轨道颜色。

Step 3: 温度单位转换

目标: 在天气主页根据用户设置的温度单位自动转捾示温度值。

// service/TempConverter.ets

import { TemperatureUnit } from '../model/SettingsModel';

/** 温度转换工具 */
export class TempConverter {
  /**
   * 格式化温度显示
   * @param celsius 摄氏度温度值
   * @param unit 目标单位
   * @returns 格式化后的温度字符串
   */
  static format(celsius: number, unit: TemperatureUnit): string {
    if (unit === TemperatureUnit.FAHRENHEIT) {
      const fahrenheit = Math.round(celsius * 9 / 5 + 32);
      return `${fahrenheit}°F`;
    }
    return `${celsius}°C`;
  }

  /**
   * 转换温度数值
   * @param celsius 摄氏度
   * @param unit 目标单位
   * @returns 转换后的温度数值
   */
  static convert(celsius: number, unit: TemperatureUnit): number {
    if (unit === TemperatureUnit.FAHRENHEIT) {
      return Math.round(celsius * 9 / 5 + 32);
    }
    return celsius;
  }
}

在天气主页中引入温度转换:

// pages/Index.ets(部分修改)

import { getSettings, TemperatureUnit } from '../model/SettingsModel';
import { TempConverter } from '../service/TempConverter';

@Entry
@Component
struct WeatherPage {
  @ObjectLink settings: SettingsModel = getSettings();
  // ... 其他状态变量 ...

  build() {
    Column() {
      // ... 顶部导航栏 ...

      // 当前温度(根据单位自动转换)
      Text(TempConverter.format(this.currentCelsius, this.settings.temperatureUnit))
        .fontSize(96)
        .fontWeight(FontWeight.Light)
        .fontColor(Color.White)

      // 预报列表中的温度也需要转换
      ForEach(this.forecasts, (day: DailyForecast) => {
        Row() {
          Text(TempConverter.format(day.minTemperature, this.settings.temperatureUnit))
            .fontSize(14)
            .fontColor('#B0FFFFFF')
          // 温度条 ...
          Text(TempConverter.format(day.maxTemperature, this.settings.temperatureUnit))
            .fontSize(14)
            .fontColor(Color.White)
        }
      })
    }
  }
}

由于 settings.temperatureUnit 使用了 @Trace 装饰,当用户在设置页面切换单位后返回主页,温度显示会自动更新,无需手动刷新。

实现效果:Select 组件展开后显示摄氏度 (°C) 和华氏度 (°F) 两个选项,选中项高亮显示,切换后主页温度自动更新。

Step 4: 天气预警通知

目标: 使用 Notification Kit 创建通知渠道,在检测到天气预警时推送本地通知。

// service/NotificationService.ets

import { notificationManager } from '@kit.NotificationKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG = 'NotificationService';

/** 天气预警等级 */
export enum AlertLevel {
  BLUE = '蓝色',
  YELLOW = '黄色',
  ORANGE = '橙色',
  RED = '红色'
}

/** 预警信息 */
export interface WeatherAlert {
  id: number;
  title: string;
  content: string;
  level: AlertLevel;
  publishTime: string;
}

/** 通知服务 */
export class NotificationService {
  private channelCreated: boolean = false;

  /** 创建天气预警通知渠道 */
  async createAlertChannel(): Promise<void> {
    if (this.channelCreated) {
      return;
    }

    const slot: notificationManager.NotificationSlot = {
      type: notificationManager.SlotType.SOCIAL_COMMUNICATION,
      level: notificationManager.SlotLevel.LEVEL_HIGH,
      desc: '接收天气预警信息,包括暴雨、高温、大风等气象预警',
      name: '天气预警',
      vibrationEnabled: true,
      soundEnabled: true,
      lightEnabled: true,
      lightColor: 0xFFFF0000
    };

    try {
      await notificationManager.addSlot(slot);
      this.channelCreated = true;
      hilog.info(0x0000, TAG, '天气预警通知渠道创建成功');
    } catch (err) {
      hilog.error(0x0000, TAG, `创建通知渠道失败: ${JSON.stringify(err)}`);
    }
  }

  /** 发布天气预警通知 */
  async publishAlert(alert: WeatherAlert): Promise<void> {
    await this.createAlertChannel();

    const request: notificationManager.NotificationRequest = {
      id: alert.id,
      content: {
        notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: `${alert.level}预警:${alert.title}`,
          text: alert.content
        }
      },
      notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
      tapDismissed: true
    };

    try {
      await notificationManager.publish(request);
      hilog.info(0x0000, TAG, `预警通知发布成功: ${alert.title}`);
    } catch (err) {
      hilog.error(0x0000, TAG, `预警通知发布失败: ${JSON.stringify(err)}`);
    }
  }

  /** 取消指定预警通知 */
  async cancelAlert(alertId: number): Promise<void> {
    try {
      await notificationManager.cancel(alertId);
    } catch (err) {
      hilog.error(0x0000, TAG, `取消通知失败: ${JSON.stringify(err)}`);
    }
  }
}

实现效果:首次发布通知时,系统弹出通知授权弹窗,用户授权后状态栏显示天气预警通知卡片。

在天气服务中集成预警检查逻辑:

// service/WeatherService.ets(新增方法)

import { NotificationService, WeatherAlert, AlertLevel } from './NotificationService';

export class WeatherService {
  private notificationService: NotificationService = new NotificationService();

  /** 检查并推送天气预警 */
  async checkAndNotifyAlerts(weatherData: WeatherData): Promise<void> {
    const alerts: WeatherAlert[] = [];

    // 解析 Weather Service Kit 返回的预警数据
    if (weatherData.weatherAlerts && weatherData.weatherAlerts.length > 0) {
      for (const rawAlert of weatherData.weatherAlerts) {
        const alert: WeatherAlert = {
          id: rawAlert.alertId ?? Date.now(),
          title: rawAlert.title ?? '天气预警',
          content: rawAlert.detail ?? '请关注最新天气变化',
          level: this.parseAlertLevel(rawAlert.level),
          publishTime: rawAlert.publishTime ?? ''
        };
        alerts.push(alert);
      }
    }

    // 逐条推送预警通知
    for (const alert of alerts) {
      await this.notificationService.publishAlert(alert);
    }
  }

  /** 解析预警等级 */
  private parseAlertLevel(level: string | undefined): AlertLevel {
    switch (level) {
      case 'red':
        return AlertLevel.RED;
      case 'orange':
        return AlertLevel.ORANGE;
      case 'yellow':
        return AlertLevel.YELLOW;
      default:
        return AlertLevel.BLUE;
    }
  }
}

Notification Kit 的几个关键点:

  • 通知渠道(NotificationSlot):Android 8.0 引入的通知分类机制,HarmonyOS 同样采用。不同渠道可以设置不同的提醒方式(振动、声音、灯光)。LEVEL_HIGH 表示高优先级,会在状态栏显示 Heads-up 通知。
  • 通知 ID:相同 ID 的通知会覆盖前一条,不同 ID 的通知独立显示。预警通知建议使用预警类型作为 ID,避免重复推送。
  • tapDismissed: true:用户点击通知后自动清除,适合一次性的预警信息。

Step 5: 城市切换页面

目标: 使用 Swiper 组件实现城市卡片轮播,支持添加和管理多个城市。

// model/CityData.ets

/** 城市天气摘要 */
export interface CityWeather {
  cityName: string;
  temperature: number;
  weatherDescription: string;
  isCurrentLocation: boolean;
}

/** 城市列表管理 */
@ObservedV2
export class CityListManager {
  @Trace cities: CityWeather[] = [];
  @Trace selectedIndex: number = 0;

  addCity(city: CityWeather): void {
    this.cities = [...this.cities, city];
  }

  removeCity(index: number): void {
    const updated = this.cities.filter((_: CityWeather, i: number) => i !== index);
    this.cities = updated;
    if (this.selectedIndex >= this.cities.length) {
      this.selectedIndex = Math.max(0, this.cities.length - 1);
    }
  }
}

城市切换页面:

// pages/CityPage.ets

import { hilog } from '@kit.PerformanceAnalysisKit';
import { CityWeather, CityListManager } from '../model/CityData';
import { TempConverter } from '../service/TempConverter';
import { getSettings, TemperatureUnit } from '../model/SettingsModel';

@Entry
@Component
struct CityPage {
  @ObjectLink cityManager: CityListManager;
  @ObjectLink settings: SettingsModel = getSettings();
  @State showAddCity: boolean = false;
  @State searchKeyword: string = '';

  build() {
    Column() {
      // 顶部标题
      Row() {
        Text('城市管理')
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Blank()
        Text('+ 添加城市')
          .fontSize(14)
          .fontColor('#4A90D9')
          .onClick(() => {
            this.showAddCity = true;
          })
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 16, bottom: 12 })

      // 城市卡片轮播
      Swiper() {
        ForEach(this.cityManager.cities, (city: CityWeather, index: number) => {
          Column({ space: 12 }) {
            // 城市名称
            Row() {
              Text(city.cityName)
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(Color.White)
              if (city.isCurrentLocation) {
                Text('  定位')
                  .fontSize(12)
                  .fontColor('#B0FFFFFF')
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor('#30FFFFFF')
                  .borderRadius(8)
              }
            }

            // 天气描述
            Text(city.weatherDescription)
              .fontSize(14)
              .fontColor('#D0FFFFFF')

            // 温度
            Text(TempConverter.format(city.temperature, this.settings.temperatureUnit))
              .fontSize(64)
              .fontWeight(FontWeight.Light)
              .fontColor(Color.White)
          }
          .width('100%')
          .height(200)
          .padding(24)
          .backgroundColor('#4A90D9')
          .borderRadius(16)
          .alignItems(HorizontalAlign.Start)
        })
      }
      .index(this.cityManager.selectedIndex)
      .autoPlay(false)
      .indicator(Indicator.dot()
        .color('#CCCCCC')
        .selectedColor('#4A90D9'))
      .margin({ left: 16, right: 16 })
      .onChange((index: number) => {
        this.cityManager.selectedIndex = index;
      })

      // 城市列表
      List({ space: 8 }) {
        ForEach(this.cityManager.cities, (city: CityWeather, index: number) => {
          ListItem() {
            Row() {
              Column({ space: 4 }) {
                Text(city.cityName)
                  .fontSize(16)
                  .fontColor('#333333')
                Text(city.weatherDescription)
                  .fontSize(13)
                  .fontColor('#999999')
              }
              .alignItems(HorizontalAlign.Start)

              Blank()

              Text(TempConverter.format(city.temperature, this.settings.temperatureUnit))
                .fontSize(20)
                .fontWeight(FontWeight.Medium)
                .fontColor('#333333')

              // 删除按钮(定位城市不可删除)
              if (!city.isCurrentLocation) {
                Text('×')
                  .fontSize(20)
                  .fontColor('#CCCCCC')
                  .margin({ left: 12 })
                  .onClick(() => {
                    this.cityManager.removeCity(index);
                  })
              }
            }
            .width('100%')
            .padding(16)
            .backgroundColor(Color.White)
            .borderRadius(12)
          }
        })
      }
      .width('100%')
      .layoutWeight(1)
      .padding({ left: 16, right: 16, top: 16 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

实现效果:城市切换页面顶部为 Swiper 轮播区域,展示当前城市天气卡片(蓝色背景、城市名、天气描述、温度),底部圆点指示器高亮当前选中城市;下方列表显示所有已添加城市,支持滑动删除。

Swiper 组件的核心属性:

  • index:当前显示的页面索引,支持双向绑定
  • autoPlay:是否自动轮播,城市切换场景建议关闭
  • indicator:底部指示器样式,dot 为圆点样式
  • onChange:页面切换时的回调,参数为新的页面索引

Step 6: 页面切换动画

目标: 使用 animateTo 实现从主页到设置页面的平滑过渡效果。

// pages/Index.ets(新增动画相关代码)

@Entry
@Component
struct WeatherPage {
  // ... 其他状态 ...
  @State showSettings: boolean = false;
  @State settingsOffsetX: number = 100;
  @State settingsOpacity: number = 0;

  build() {
    Stack() {
      // 天气主页内容
      Column() {
        // ... 主页内容 ...
      }
      .width('100%')
      .height('100%')

      // 设置页面(覆盖层)
      if (this.showSettings) {
        SettingsPage()
          .width('100%')
          .height('100%')
          .translate({ x: `${this.settingsOffsetX}%` })
          .opacity(this.settingsOpacity)
      }
    }
    .width('100%')
    .height('100%')
  }

  /** 打开设置页面(带动画) */
  openSettings(): void {
    this.showSettings = true;
    animateTo({
      duration: 300,
      curve: Curve.EaseOut,
      onFinish: () => {
        hilog.info(0x0000, 'Anim', '设置页面打开动画完成');
      }
    }, () => {
      this.settingsOffsetX = 0;
      this.settingsOpacity = 1;
    });
  }

  /** 关闭设置页面(带动画) */
  closeSettings(): void {
    animateTo({
      duration: 250,
      curve: Curve.EaseIn,
      onFinish: () => {
        this.showSettings = false;
      }
    }, () => {
      this.settingsOffsetX = 100;
      this.settingsOpacity = 0;
    });
  }
}

实现效果:点击设置入口后,设置页面从右侧滑入(translate + opacity 动画),300ms EaseOut 曲线;关闭时反向滑出,250ms EaseIn 曲线。

animateTo 是 ArkUI 提供的显式动画 API,核心参数:

  • duration:动画时长,毫秒为单位。打开页面用 300ms 稍慢以突出效果,关闭用 250ms 稍快以提升响应感。
  • curve:动画曲线。EaseOut 表示先快后慢,适合进入动画;EaseIn 表示先慢后快,适合退出动画。
  • onFinish:动画结束回调。关闭动画结束后隐藏组件,避免组件仍占据布局空间。
  • 闭包内的属性变化会被框架自动捕获并生成过渡动画,无需手动计算关键帧。

Step 7: 下拉刷新

目标: 为天气主页添加下拉刷新功能,提升用户体验。

// pages/Index.ets(下拉刷新相关代码)

@Entry
@Component
struct WeatherPage {
  @State isRefreshing: boolean = false;
  @State refreshProgress: number = 0;

  build() {
    Column() {
      // 顶部导航栏
      this.TopBarBuilder()

      // 可下拉刷新区域
      Refresh({ refreshing: $$this.isRefreshing }) {
        // 天气内容
        if (this.isLoading) {
          this.LoadingBuilder()
        } else if (this.errorMsg) {
          this.ErrorBuilder()
        } else {
          this.WeatherContentBuilder()
        }
      }
      .onRefreshing(() => {
        // 触发刷新
        this.refreshWeatherData();
      })
      .refreshOffset(64)
      .pullToRefresh(true)
    }
    .width('100%')
    .height('100%')
    .linearGradient({
      direction: GradientDirection.Bottom,
      colors: [['#4A90D9', 0.0], ['#74B9FF', 0.5], ['#A8D8EA', 1.0]]
    })
  }

  /** 刷新天气数据 */
  async refreshWeatherData(): Promise<void> {
    this.isRefreshing = true;
    try {
      await this.loadWeatherData();
    } finally {
      this.isRefreshing = false;
    }
  }
}

实现效果:用户在天气主页下拉时,顶部出现刷新指示器(旋转加载图标),松手后自动请求最新天气数据,完成后指示器消失。

Refresh 组件的关键点:

  • $$this.isRefreshing:使用双向绑定语法 $$ 将组件的 refreshing 状态与变量同步。
  • onRefreshing:下拉触发刷新时的回调,在此执行数据加载逻辑。
  • refreshOffset:触发刷新的下拉距离阈值,单位 vp。
  • pullToRefresh(true):启用下拉刷新手势。

关键代码解读

Preferences 与 AppStorageV2 联动

本篇的核心设计是将 Preferences 的持久化能力与 AppStorageV2 的响应式能力结合:

// 连接全局设置
@ObjectLink settings: SettingsModel = getSettings();

AppStorageV2.connect 确保整个应用共享同一个 SettingsModel 实例。任何页面修改设置后,其他页面通过 @ObjectLink 引用同一实例的组件会自动刷新。这比传统的"Preferences 读取 → @State 赋值"模式更简洁,也避免了多页面数据不同步的问题。

数据流向:

用户操作 → @Trace 属性变化 → UI 自动刷新
                ↓
        SettingsService.saveSetting()
                ↓
          Preferences.flush() → 磁盘持久化

反向加载:

应用启动 → SettingsService.init() → Preferences 加载到内存
                ↓
        SettingsService.loadSettings()
                ↓
        @Trace 属性赋值 → UI 自动刷新

animateTo 动画设计原则

animateTo({
  duration: 300,
  curve: Curve.EaseOut,
  onFinish: () => { /* 动画结束回调 */ }
}, () => {
  // 在闭包中修改的属性会被自动捕获为动画目标
  this.settingsOffsetX = 0;
  this.settingsOpacity = 1;
});

animateTo 的设计遵循"声明目标状态"的理念:你只需要告诉框架最终状态是什么,框架会自动计算中间帧。这与 CSS transition 的思路一致,但更强大:

  • 支持同时动画多个属性(位移 + 透明度)
  • 支持动画曲线(Curve 枚举提供多种预设曲线)
  • 支持动画完成回调(onFinish),可以在动画结束后执行逻辑

注意 onFinish 的使用时机:关闭动画结束后才设置 showSettings = false,如果在动画开始前就隐藏组件,用户会看到组件突然消失而不是滑出消失。

通知渠道最佳实践

const slot: notificationManager.NotificationSlot = {
  type: notificationManager.SlotType.SOCIAL_COMMUNICATION,
  level: notificationManager.SlotLevel.LEVEL_HIGH,
  vibrationEnabled: true,
  soundEnabled: true
};

通知渠道的 level 决定了通知的打扰程度:

Level 行为 适用场景
LEVEL_NONE 静默通知,不振动不响铃 后台数据同步
LEVEL_LOW 仅在状态栏显示 普通信息推送
LEVEL_DEFAULT 默认行为 一般应用通知
LEVEL_HIGH Heads-up 弹出显示 天气预警、紧急通知

天气预警属于高优先级信息,使用 LEVEL_HIGH 确保用户能及时看到。但不要滥用高优先级,否则用户会关闭通知权限。


总结

本文为天气 App 补齐了通知和设置功能,主要实现了:

  1. 设置模型与持久化:使用 @ObservedV2 + @Trace 定义响应式设置模型,通过 Preferences 实现设置数据的磁盘持久化
  2. 设置页面 UI:使用 Select 组件实现温度单位选择,Toggle 组件实现开关类设置
  3. 温度单位转换:封装 TempConverter 工具类,在所有温度显示处统一处理单位转换
  4. 天气预警通知:使用 Notification Kit 创建高优先级通知渠道,在检测到气象预警时推送本地通知
  5. 城市切换:使用 Swiper 实现城市卡片轮播,支持添加和删除城市
  6. 页面切换动画:使用 animateTo 实现设置页面的滑入/滑出过渡效果
  7. 下拉刷新:使用 Refresh 组件实现天气数据的手动刷新

关键注意事项:

  • Preferencesput 操作仅修改内存缓存,必须调用 flush 才能持久化到磁盘
  • 通知渠道必须在发布通知前创建,且同一渠道重复创建不会报错
  • animateTo 闭包内的属性变化才会被动画化,闭包外的变化是即时的
  • @ObjectLink 必须配合 @ObservedV2 类使用,不能用于普通类

下篇预告

下一篇: 待办 App(上):任务列表与数据持久化

第三篇我们将开始全新的待办 App 项目,学习如何使用 relationalStore 实现本地数据持久化,以及使用 List + LazyForEach 高效渲染长列表。


Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐