HarmonyOS NEXT 中级

动画

  • 属性动画, 转卖动画
    • 应用内转场尽量不要调起别的UIAbility,而是自行写组件然后跳转,不然会增加额外的独立任务
    • 不可动画属性:zIndex、focusable, enalbed等(即基本上不可插值)
  • 变化过程中参数值并非绝对的连续,而是具有一定的离散性。但由于人眼会产生视觉暂留
  • UI的一次改变称为一个动画帧,对应一次屏幕刷新。
  • ArkUI提供@AnimatableExtend装饰器用于自定义可动画属性
  • 属性动画应该作用于始终存在的组件,对于将要出现或者将要消失的组件的动画应该使用转场动画
  • 尽量不要使用动画结束回调。属性动画是对已经发生的状态进行的动画,不需要开发者去处理结束的逻辑。如果要使用结束回调,一定要正确处理连续操作的数据管理。

观察以下动画 右边的红矩形不会在蓝色矩形旋转过程中自动让出位置,所以其实就是三个可变属性:

  1. 旋转角度
  2. 透明度
  3. 偏移量(其实就是对角线比边长多出的部分)

属性动画

对于一直存在的组件, 用属性动画,对于即将出现或者消失的组件,用转场动画

animateTo

类似iOS的animate方法,直接在闭包内设置状态终止值即可

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

@Entry
@Component
struct AnimateToDemo {
  @State animate: boolean = false;
  // 第一步: 声明相关状态变量
  @State rotateValue: number = 0; // 组件一旋转角度
  @State translateX: number = 0; // 组件二偏移量
  @State opacityValue: number = 1; // 组件二透明度

  // 第二步:将状态变量设置到相关可动画属性接口
  build() {
    Row() {
      // 组件一
      Column() {
      }
      .rotate({ angle: this.rotateValue })
      .backgroundColor('#317AF7')
      .justifyContent(FlexAlign.Center)
      .width(100)
      .height(100)
      .borderRadius(30)
      .onClick(() => {
        this.getUIContext()?.animateTo({ curve: curves.springMotion() }, () => {
          this.animate = !this.animate;
          this.rotateValue = this.animate ? 90 : 0;
          this.opacityValue = this.animate ? 0.6 : 1;
          this.translateX = this.animate ? 50 : 0;
        })
      })

      // 组件二
      Column() {

      }
      .justifyContent(FlexAlign.Center)
      .width(100)
      .height(100)
      .backgroundColor('#D94838')
      .borderRadius(30)
      .opacity(this.opacityValue)
      .translate({ x: this.translateX })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

animate这个属性在这个例子里是没用的

animation

需要设置一下animation属性,然后会在每次状态改变时自动应用这里配置的属性

@State animate: boolean = false;
build() {
  ...
  Column() {}
  .animation({curve: curves.springMotion()})
  .onClick(() => {
    this.animate = !this.animate;
    this.rotateValue = this.animate ? 90 : 0;
    this.translateX = this.animate ? 50 : 0;
    this.opacityValue = this.animate ? 0.6 : 1;
  })
  Column() {}
  .translate({ x: this.translateX })
  .animation({ curve: curves.springMotion() })
  ...
}

keyframeAnimateTo

.onClick(() => {
  this.getUIContext()?.keyframeAnimateTo({
    iterations: 1
  }, [
    {
      // 第一段关键帧动画时长为800ms,组件一顺时针旋转90度,组件二的透明度变从1变为0.6,组件二的translate从0位移到50
      duration: 800,
      event: () => {
        this.rotateValue = 90;
        this.opacityValue = 0.6;
        this.translateX = 50;
      }
    },
    {
      // 第二段关键帧动画时长为500ms,组件一逆时针旋转90度恢复至0度,组件二的透明度变从0.6变为1,组件二的translate从50位移到0
      duration: 500,
      event: () => {
        this.rotateValue = 0;
        this.opacityValue = 1;
        this.translateX = 0;
      }
    }
  ]);
})

可见, 关键帧里只需要用event来定义状态变化即可. 但这不是一个好例子,它只是做了一个往复动画, 演示关键帧最好是把一个单程动画拆成多段

转场动画

  • 使用导航转场Navigaition实现页面间转场动画以及自定义导航转场动画。
  • 使用bindSheet实现模态转场动画。
  • 使用transition属性实现组件内转场动画。
  • 使用geometryTransition实现共享元素转场动画及一镜到底动效。

组件内转场用transition属性, 注意需要成对出现"出现"和"消失"的转场设置, 这点跟SwiftUI很像.

@Component
export struct ComponentTransition {
  @State isShow: boolean = false;
  appearEffect = TransitionEffect.scale({ x: 0, y: 0 }).combine(TransitionEffect.OPACITY);
  disappearEffect = TransitionEffect.rotate({ x: 0, y: 1, z: 0, angle: 360 }).combine(TransitionEffect.OPACITY);

  build() {
    NavDestination() {
      Column() {
        if (this.isShow) {
          Image($r('app.media.bg_element'))
            .TransitionEleStyles()
            .transition(TransitionEffect.asymmetric(this.appearEffect, this.disappearEffect))
        }
        Image($r('app.media.bg_element'))
          // ...
      }
      .padding(16)
      .height('100%')
      .width('100%')
    }
    .title(getResourceString($r('app.string.title_component_transition'), getContext(this)))
  }
}

共享元素转场通过给组件设置geometryTransition属性来实现,相同或者相似的两个组件配置为同一个id,则转场过程中会执行共享元素转场。

// entry/src/main/ets/pages/geometrytransition/GeometryTransition.ets
import { GEOMETRY_TRANSITION_ID } from '../../constants/CommonConstants';

Column() {
  Image($r('app.media.bg_transition'))
    .width('100%')
    .height(147)
    .borderRadius(8)
    .margin({ bottom: 12 })
    .geometryTransition(GEOMETRY_TRANSITION_ID)
    // ...
}

// entry/src/main/ets/pages/geometrytransition/GeometryTransitionDetail.ets
import { GEOMETRY_TRANSITION_ID } from '../../constants/CommonConstants';

NavDestination() {
  Image($r('app.media.bg_transition'))
    .width('100%')
    .height('60%')
    .margin({ top: 12 })
    .geometryTransition(GEOMETRY_TRANSITION_ID)
}

触发的话, 把push nav stack放到animateTo里即可, 这是我没想到的:

.onClick(() => {
  this.getUIContext().animateTo({ duration: 600 }, () => {
    this.navPageInfos.pushPath({ name: 'GeometryTransitionDetail' }, false);
  });
})

// 返回也是:
NavDestination() {
  Image($r('app.media.bg_transition'))
}
.transition(TransitionEffect.OPACITY)
.title('xxx')
.onBackPressed(() => {
  this.getUIContext().animateTo({ duration: 600 }, () => {
    this.navPageInfos.pop(false);
  });
  return true;
})

弹窗

  • Dialog、Popup、Menu、OverlayManager、Toast、bindSheet以及bindContentCover都是弹窗。
    • Toast属于“非模态弹窗“

前提条件(约束)

  • 可以通过调用UIContext或getUIContext,在非UI页面或某些异步回调中使用本文中的接口。CalendarPickerDialog当前不支持此操作。
  • 操作菜单 (showActionMenu)、对话框 (showDialog)需先使用UIContext中的getPromptAction()方法获取到PromptAction对象,再通过该对象调用对应方法。
  • 列表选择弹出框 (ActionSheet)、警告弹出框 (AlertDialog)、选择器弹出框 (PickerDialog)中除CalendarPickerDialog都需先使用ohos.window中的getUIContext()方法获取UIContext实例,再通过此实例调用对应方法。或者可以通过自定义组件内置方法getUIContext()获取。

固定样式弹窗

  • 固定样式弹出框采用固定的布局格式
  • 生命周期:onWillAppear -> onDidAppear -> onWillDisappear -> onDidDisappear

showActionMenu

操作菜单通过UIContext中的getPromptAction方法获取到PromptAction对象,再通过该对象调用showActionMenu接口实现,支持在回调或开发者自定义类中使用。

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

@Entry
@Component
struct Index {
  promptAction: PromptAction = this.getUIContext().getPromptAction();

  build() {
    Column() {
      Button('showActionMenu')
        .onClick(() => {
          this.promptAction.showActionMenu({
            title: 'showActionMenu Title Info',
            buttons: [
              {
                text: 'item1',
                color: '#666666'
              },
              {
                text: 'item2',
                color: '#000000'
              },
            ]
          })
            .then(data => {
              console.info('showActionMenu success, click button: ' + data.index);
            })
            .catch((err: Error) => {
              console.error('showActionMenu error: ' + err);
            })
        })
    }.height('100%').width('100%')
  }
}

showDialog

Button('showDialog')
  .onClick(() => {
    try {
      this.promptAction.showDialog({
        title: 'showDialog Title Info',
        message: 'Message Info',
        buttons: [
          {
            text: 'button1',
            color: '#000000'
          },
          {
            text: 'button2',
            color: '#000000'
          }
        ]
      }, (err, data) => {
        if (err) {
          console.error('showDialog err: ' + err);
          return;
        }
        console.info('showDialog success callback, click button: ' + data.index);
      });
    } catch (error) {
      let message = (error as BusinessError).message;
      let code = (error as BusinessError).code;
      console.error(`showDialog args error code is ${code}, message is ${message}`);
    };
  })

选择器弹窗

CalendarPickerDialog

日历选择器弹窗的弹出依赖UI的执行上下文,不可在UI上下文不明确的地方使用

.onClick(() => {
  console.info('CalendarDialog.show');
  CalendarPickerDialog.show({
    selected: this.selectedDate,
    acceptButtonStyle: {
      fontColor: '#2787d9',
      fontSize: '16fp',
      backgroundColor: '#f7f7f7',
      borderRadius: 10
    },
    cancelButtonStyle: {
      fontColor: Color.Red,
      fontSize: '16fp',
      backgroundColor: '#f7f7f7',
      borderRadius: 10
    },
    onAccept: (date: Date) => {
      // 当弹出框再次弹出时显示选中的是上一次确定的日期
      this.selectedDate = date;
    }
  })
})

DatePickerDialog

就是个滚轮,弹窗中配置DatePickerDialogOprionslunarSwitchshowTime属性为true时,会展示切换农历的开关和时间

.onClick(() => {
  this.getUIContext().showDatePickerDialog({
    start: new Date("2000-1-1"),
    end: new Date("2100-12-31"),
    selected: this.selectTime,
    lunarSwitch: true,
    showTime: true,
    onDateAccept: (value: Date) => {
      this.selectTime = value;
      console.info("DatePickerDialog:onAccept()" + JSON.stringify(value));
    },
  });
})

可以通过配置disappearTextStyletextStyleselectedTextStyleacceptButtonStyle、cancelButtonStyle等实现自定义文本以及按钮样式。

TimePickerDialog

.onClick(() => {
  this.getUIContext().showTimePickerDialog({
    selected: this.selectTime,
    textStyle: { color: '#2787d9', font: { size: '14fp', weight: FontWeight.Normal } },
    selectedTextStyle: { color: '#004aaf', font: { size: '18fp', weight: FontWeight.Regular } },
    acceptButtonStyle: {
      fontColor: '#2787d9',
      fontSize: '16fp',
      backgroundColor: '#f7f7f7',
      borderRadius: 10
    },
    cancelButtonStyle: {
      fontColor: Color.Red,
      fontSize: '16fp',
      backgroundColor: '#f7f7f7',
      borderRadius: 10
    }
  });
})

顺便演示了自定义属性

TextPickerDialog

  • 这是自定义滚轮,比如省市区三联动
  • 需要提供结构化的数据源TextCascadePickerRangeContent[],主要就是本级的文本以同样结构的子级
@Entry
@Component
struct TextPickerDialogExample {
  private fruits: c[] = [
    {
      text: '辽宁省',
      children: [{ text: '沈阳市', children: [{ text: '沈河区' }, { text: '和平区' }, { text: '浑南区' }] },
        { text: '大连市', children: [{ text: '中山区' }, { text: '金州区' }, { text: '长海县' }] }]
    },
    {
      text: '吉林省',
      children: [{ text: '长春市', children: [{ text: '南关区' }, { text: '宽城区' }, { text: '朝阳区' }] },
        { text: '四平市', children: [{ text: '铁西区' }, { text: '铁东区' }, { text: '梨树县' }] }]
    },
    {
      text: '黑龙江省',
      children: [{ text: '哈尔滨市', children: [{ text: '道里区' }, { text: '道外区' }, { text: '南岗区' }] },
        { text: '牡丹江市', children: [{ text: '东安区' }, { text: '西安区' }, { text: '爱民区' }] }]
    }
  ];
  private select : number  = 0;
  build() {
    Column() {
      Button('showTextPickerDialog')
        .margin(30)
        .onClick(() => {
          this.getUIContext().showTextPickerDialog({
            range: this.fruits,
            selected: this.select,
            onAccept: (value: TextPickerResult) => {
              this.select = value.index as number
            }
          });
        })
    }.width('100%').margin({ top: 5 })
  }
}

从上方例子看出, 标准的层级数据源结构不包含ID,如果你需要的数据源包含ID,那么需要自己实现一个转换器,将数据源转换为标准结构(主要是提供一个映射,能从选中的文本组合,找回对应的ID)

或者,手动实现,思路就是每一个选项存为状态,这样可以触发下级的自动刷新,然后手动拼接TextPicker, 选中事件一般是onChange

TextPicker({ range: this.provinces, selected: this.provinceIndex })
  .onChange((value: number) => {
    this.provinceIndex = value;
    this.updateCities(); // 省份改变时更新城市列表
  })

private updateCities() {
  const selectedProvince = this.provinces[this.provinceIndex];
  this.cities = this.cityData.get(selectedProvince) || [];
  this.cityIndex = 0; // 重置城市索引
  this.updateDistricts(); // 城市列表更新后,同时更新区县列表
}

注意, updateCitiesupdateDistricts也一样),不是为了触发UI刷新,而是为了更新下级数据源(状态),通过状态变更触发下级刷新

ActionSheet

一个带了自定义样式和转场效果的例子:

@Entry
@Component
struct showActionSheetExample {
  build() {
    Column() {
      Button('showActionSheet')
        .margin(30)
        .onClick(() => {
          this.getUIContext().showActionSheet({
            title: 'ActionSheet title',
            message: 'message',
            autoCancel: false,
            width: 300,
            height: 300,
            cornerRadius: 20,
            borderWidth: 1,
            borderStyle: BorderStyle.Solid,
            borderColor: Color.Blue,
            backgroundColor: Color.White,
            transition: TransitionEffect.asymmetric(TransitionEffect.OPACITY
              .animation({ duration: 3000, curve: Curve.Sharp })
              .combine(TransitionEffect.scale({ x: 1.5, y: 1.5 }).animation({ duration: 3000, curve: Curve.Sharp })),
              TransitionEffect.OPACITY.animation({ duration: 100, curve: Curve.Smooth })
                .combine(TransitionEffect.scale({ x: 0.5, y: 0.5 }).animation({ duration: 100, curve: Curve.Smooth }))),
            confirm: {
              value: 'Confirm button',
              action: () => {
                console.info('Get Alert Dialog handled');
              }
            },
            alignment: DialogAlignment.Center,
            sheets: [
              {
                title: 'apples',
                action: () => {
                }
              },
              {
                title: 'bananas',
                action: () => {
                }
              },
              {
                title: 'pears',
                action: () => {
                  console.info('pears');
                }
              }
            ]
          });
        })
    }.width('100%').margin({ top: 5 })
  }
}

AlertDialog

@Entry
@Component
.onClick(() => {
  this.getUIContext().showAlertDialog(
    {
      title: 'title',
      message: 'text',
      alignment: DialogAlignment.Center,
      offset: { dx: 0, dy: -20 },
      gridCount: 3, // ?
      buttons: [{
        value: 'cancel',
        action: () => {
          console.info('Callback when the first button is clicked');
        }
      },
        {
          enabled: true,
          defaultFocus: true,
          style: DialogButtonStyle.HIGHLIGHT,
          value: 'ok',
          action: () => {
            console.info('Callback when the second button is clicked');
          }
        }],
    }
  );
})

openCustomDialog

import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct Index {
  private customDialogComponentId: number = 0;

  @Builder
  customDialogComponent() {
    Row({ space: 50 }) {
      Button("这是一个弹窗")
    }.height(200).padding(5)
  }

  build() {
    Row() {
      Row({ space: 20 }) {
        Text('打开弹窗')
          .fontSize(30)
          .onClick(() => {
            this.getUIContext()
              .getPromptAction()
              .openCustomDialog({
                builder: () => {
                  this.customDialogComponent()
                },
                isModal: true,
                showInSubWindow: false,
                maskColor: Color.Pink,
                maskRect: {
                  x: 20,
                  y: 20,
                  width: '90%',
                  height: '90%'
                },

                dialogTransition: // 设置弹窗内容显示的过渡效果
                TransitionEffect.translate({ x: 0, y: 290, z: 0 })
                  .animation({ duration: 4000, curve: Curve.Smooth }), // 四秒钟的偏移渐变动画

                maskTransition: // 设置蒙层显示的过渡效果
                TransitionEffect.opacity(0)
                  .animation({ duration: 4000, curve: Curve.Smooth }) // 四秒钟的透明渐变动画

              })
              .then((dialogId: number) => {
                this.customDialogComponentId = dialogId;
              })
              .catch((error: BusinessError) => {
                console.error(`openCustomDialog error code is ${error.code}, message is ${error.message}`);
              })
          })
      }
      .width('100%')
    }
    .height('100%')
  }
}

如果在弹窗里有TextInput,可以设置软键盘避让

  @Builder
  customDialogComponent() {
      Column() {
        Text('keyboardAvoidDistance: 0vp')
          .fontSize(20)
          .margin({ bottom: 36 })
        TextInput({ placeholder: '' })
      }.backgroundColor('#FFF0F0F0')
  }

this.getUIContext().getPromptAction().openCustomDialog({
  builder: () => {
    this.customDialogComponent()
  },
  alignment: DialogAlignment.Bottom,
  keyboardAvoidMode: KeyboardAvoidMode.DEFAULT, // 软键盘弹出时,弹出框自动避让
  keyboardAvoidDistance: LengthMetrics.vp(0) // 软键盘弹出时与弹出框的距离为0vp
})

气泡提示

Button()
  .onClick(() => {
    this.customPopup = !this.customPopup
  })
  .bindPopup(this.customPopup, {
    builder: this.PopupBuilder, // Bubble content
    placement: Placement.Bottom, // The pop position of the bubble
    onStateChange: (e) => {
      if (!e.isVisible) {
        this.customPopup = false;
      }
    }
  })

@Builder
PopupBuilder() {
  Row({ space: 2 }) {
    Text('保存信息')
  }
}

Toast

需要promptAction

import { promptAction } from '@kit.ArkUI';
// 给上面的PopupBuilder气泡添加一个点击事件:
Row()...
.onClick(() => {
  this.isSaved = true;
  this.customPopup = false;  // Close Popup dialog
  this.ctx.getPromptAction().showToast({
    message: $r('app.string.save_successfully'),
    duration: 2000
  })
})

自定义弹窗

见实例

手势

onClick

  • 等同于将count配置为1的TapGesture,即单击手势。
private judgeCount: number = 0

increaseJudgeGuard(): void {
  this.judgeCount++
}

Parent() {
  Child()
    .onClick(() => { // 1. 子组件上注册了点击事件,正常情况下点击在子组件上时,优先得到响应
      console.info('Clicked on child')
      this.increaseJudgeGuard()
    })
    .onGestureJudgeBegin((gestureInfo: GestureInfo, event: BaseGestureEvent) => {
  // 3. 当数字增长为5的倍数时禁用子组件上的点击手势,这样父组件上的点击可以得到响应
  // 这里演示对手势进行干预
    if (this.judgeCount % 5 == 0 && gestureInfo.type == GestureControl.GestureType.CLICK) {
        return GestureJudgeResult.REJECT
      } else {
        return GestureJudgeResult.CONTINUE
      }
    })
}
.gesture(
  TapGesture() // 2. 父组件上注册了点击手势,正常情况下点击在子组件区域时,父组件上的手势优先级低于子组件
    .onAction(() => {
      console.info('Clicked on parent')
      this.increaseJudgeGuard()
    }))

示例中,每点击5次,子组件的点击事件将临时禁用1次,确保父组件点击优先响应。

TapGesture

参数定义

Text('Click twice').fontSize(28)
.gesture(
// 绑定count为2的TapGesture
TapGesture({ count: 2 })
  .onAction((event: GestureEvent|undefined) => {
  if(event){
    this.value = JSON.stringify(event.fingerList[0]);
  }
  }))

fingerList有这些信息:

LongPressGesture

参数定义

@State count: number = 0;
build() {
  Text('LongPress OnAction:' + this.count).fontSize(28)
  .gesture(
    // 绑定可以重复触发的LongPressGesture
    LongPressGesture({ repeat: true })
      .onAction((event: GestureEvent|undefined) => {
        if(event){
          if (event.repeat) {
            this.count++;
          }
        }
      })
      .onActionEnd(() => {
        this.count = 0;
      })
  )
}

支持按住不动反复触发,onActionEnd()在松开时触发

PanGesture

滑动达到最小滑动距离(默认值为5vp)时滑动手势识别成功,参数定义

.gesture(
  PanGesture()
    .onActionStart(() => {
      console.info("Pan start");
    })
    .onActionUpdate((event: GestureEvent) => {
      // 单指上下滑动
      if (event.source === SourceType.TouchScreen) {
        console.info("finger move triggered PanGesture");
        this.handlePanUpdate(event);
      }
      if (event.source === SourceType.Mouse && event.sourceTool === SourceTool.MOUSE) {
        // 鼠标左键按住上下滑动或者触控板单指按住上下滑动
        if (event.axisHorizontal === 0 && event.axisVertical === 0) {
          console.info("mouse move with left button pressed triggered PanGesture");
          this.handlePanUpdate(event);
        } else { // 鼠标滚轮滚动
          console.info("mouse wheel triggered PanGesture");
          this.handleWheelEvent(event);
        }
      }
      if (event.sourceTool === SourceTool.TOUCHPAD && (event.axisHorizontal !== 0 || event.axisVertical !== 0)) {
        console.info("touchpad double finger move triggered PanGesture");
        this.handleTouchPadScroll(event);
      }
    })
)

上例完整示例中,touchpad的移动用了event.offsetY*0.02,别的都乘0.1,注意下差别

PinchGesture

参数定义

.scale({ x: this.scaleValue, y: this.scaleValue, z: 1 })
.gesture(
  // 在组件上绑定三指触发的捏合手势
  PinchGesture({ fingers: 3 })
    .onActionStart((event: GestureEvent|undefined) => {
      console.info('Pinch start');
    })
      // 当捏合手势触发时,可以通过回调函数获取缩放比例,从而修改组件的缩放比例
    .onActionUpdate((event: GestureEvent|undefined) => {
      if(event){
        this.scaleValue = this.pinchValue * event.scale;
        this.pinchX = event.pinchCenterX;
        this.pinchY = event.pinchCenterY;
      }
    })
    .onActionEnd(() => {
      this.pinchValue = this.scaleValue;
      console.info('Pinch end');
    })
)

RotationGesture

参数定义

.rotate({ angle: this.angle })
.gesture(
  RotationGesture()
    .onActionStart((event: GestureEvent|undefined) => {
      console.info('RotationGesture is onActionStart');
    })
      // 当旋转手势生效时,通过旋转手势的回调函数获取旋转角度,从而修改组件的旋转角度
    .onActionUpdate((event: GestureEvent|undefined) => {
      if(event){
        this.angle = this.rotateValue + event.angle;
      }
      console.info('RotationGesture is onActionUpdate');
    })
      // 当旋转结束抬手时,固定组件在旋转结束时的角度
    .onActionEnd(() => {
      this.rotateValue = this.angle;
      console.info('RotationGesture is onActionEnd');
    })
    .onActionCancel(() => {
      console.info('RotationGesture is onActionCancel');
    })
)

SwipeGesture

参数定义

.rotate({ angle: this.rotateAngle })
.gesture(
  // 绑定快滑手势且限制仅在竖直方向滑动时触发
  SwipeGesture({ direction: SwipeDirection.Vertical })
    // 当快滑手势触发时,获取滑动的速度和角度,实现对组件的布局参数的修改
    .onAction((event: GestureEvent|undefined) => {
      if(event){
        this.speed = event.speed;
        this.rotateAngle = event.angle;
      }
    })
)

当SwipeGesture和PanGesture同时绑定时,SwipeGesture的触发条件为滑动速度达到100vp/s,PanGesture的触发条件为滑动距离达到5vp,先达到触发条件的手势触发。可以通过修改SwipeGesture和PanGesture的参数以达到不同的效果。

组合手势

支持顺序识别、并行识别和互斥识别三种类型。

// 顺序识别
// 长按之后拖动
.gesture(
  // 声明该组合手势的类型为Sequence类型
  GestureGroup(GestureMode.Sequence,
    // 该组合手势第一个触发的手势为长按手势,且长按手势可多次响应
    LongPressGesture({ repeat: true })
      // 当长按手势识别成功,增加Text组件上显示的count次数
      .onAction((event: GestureEvent|undefined) => {
        if(event){
          if (event.repeat) {
            this.count++;
          }
        }
        console.info('LongPress onAction');
      })
      .onActionEnd(() => {
        console.info('LongPress end');
      }),
    // 应该是触发了长按手势成功后(比如设定为3秒才生效),才触发拖动手势
    PanGesture()
      .onActionStart(() => {
        this.borderStyles = BorderStyle.Dashed;
        console.info('pan start');
      })
        // 当该手势被触发时,根据回调获得拖动的距离,修改该组件的位移距离从而实现组件的移动
      .onActionUpdate((event: GestureEvent|undefined) => {
        if(event){
          this.offsetX = (this.positionX + event.offsetX);
          this.offsetY = this.positionY + event.offsetY;
        }
        console.info('pan update');
      })
      .onActionEnd(() => {
        this.positionX = this.offsetX;
        this.positionY = this.offsetY;
        this.borderStyles = BorderStyle.Solid;
      })
  )
  .onCancel(() => {
    console.info("sequence gesture canceled")
  })
)

// 并行手势
// 比如单击算单击的双击算双击的,各自达到条件就触发
.gesture(
  GestureGroup(GestureMode.Parallel,
    TapGesture({ count: 1 })
      .onAction(() => {
        this.count1++;
      }),
    TapGesture({ count: 2 })
      .onAction(() => {
        this.count2++;
      })
  )
)

上例中, 如果Parallel改为Exclusive,则单击和双击互斥,只能触发一个,事实上,如果只是点击屏幕,那么单击永远会先达到,上例中,双击手势就永远触发不了。

onGestureRecognizerJudgeBegin来判断(仲裁)手势的优先级,注意跟前面的onGestureJudgeBegin区分,一个是用在单手势,一个是用在多手势。

.onGestureRecognizerJudgeBegin((event: BaseGestureEvent, current: GestureRecognizer, others: Array<GestureRecognizer>)=>{
  if (current.getType() !== GestureControl.GestureType.PAN_GESTURE) {
    return GestureJudgeResult.CONTINUE;
  }
  if (this.isLongPress) {
    return GestureJudgeResult.CONTINUE;
  }
  return GestureJudgeResult.REJECT;
})

多层级手势

  • 触摸事件(onTouch事件)是所有手势组成的基础,有Down,Move,Up,Cancel四种。
  • 对于一般的容器组件(例如:Column),父子组件之间onTouch事件能够同时触发。

同时触发的意思是如果加了事件回调,都能进入:

ComponentA() {
    ComponentB().onTouch(() => {})
    ComponentC().onTouch(() => {})
}.onTouch(() => {})

点击B,A的onTouch和B的onTouch都会触发,而C的onTouch不会触发。

responseRegion

指定可以响应手势的区域:

ComponentA() {
    ComponentB()
    .onTouch(() => {})
    .gesture(TapGesture({count: 1}))
    .responseRegion({Rect1, Rect2, Rect3})
}
.onTouch(() => {})
.gesture(TapGesture({count: 1}))
.responseRegion({Rect4})

hitTestBehavior

hitTest在iOS里是找能响应的View,所以是自顶向下的,从示例代码来看,鸿蒙里也差不多是这个意思,也是自顶向下的,然后通过配置block还是transparent来决定是否向下找。

ComponentA() {
    ComponentB()
    .onTouch(() => {})
    .gesture(TapGesture({count: 1}))

    ComponentC() {
        ComponentD()
        .onTouch(() => {})
        .gesture(TapGesture({count: 1}))
    }
    .onTouch(() => {})
    .gesture(TapGesture({count: 1}))
    .hitTestBehavior(HitTestMode.Block)
}
.onTouch(() => {})
.gesture(TapGesture({count: 1}))

这里,C上配置了block,那么D就接不到onTouch了,这样也没法响应TapGesture,也就拦截不了TapGesture了,结果就是onTouch和TapGesture都作用在了C上。

Stack({alignContent: Alignment.Center}) {
  Row()
    .onTouch(() => {console.log('touch BBB')})
    .gesture(TapGesture({count: 1}).onAction(()=>console.log("BBBBB")))
    .background(Color.Red)
    .width('80%')
    .height('80%')

  Row()
    .onTouch(() => {console.log('touch CCC')})
    .gesture(TapGesture({count: 1}).onAction(()=>console.log("CCCCC")))
    .hitTestBehavior(HitTestMode.Transparent)
    .background(Color.Yellow)
    .width('60%')
    .height('60%')
}
.onTouch(() => {console.log('touch AAA')})
.gesture(TapGesture({count: 1}).onAction(()=>console.log("AAAAA")))
.width('100%')
.height('100%')
  • 堆叠的例子里, C挡住了B,但因为配置了transparent,B也能接到onTouch
  • 但是C最先触发了tapGesture,不会继续传播
  • 不管B和C怎么遮挡,在它们上面触摸,本质上都是在A(Stack)上触摸,所以一定能触发A的onTouch,这要理解一下,容器组件和它的子元素还是有区别的

继续这个例子,如果想要Stack接不到onTouch怎么办?配置配置为BLOCKBLOCK_HIERARCHY,阻止向下继续hit test。这样触摸了C就结束了。

再继续,如果要忽略对C的手势,配置None, 这样首先就是B接到手势了。(默认是Default

优先级

ComponentA() {
    ComponentB()
    .gesture(TapGesture({count: 1}))
}
.priorityGesture(TapGesture({count: 1})) // 优先响应
.parallelGesture(TapGesture({count: 1})) // 并行响应

父组件将会响应到tapgesture

以下是传递事件到Overlay下的伪代码:

@Builder
function builderOverlay(params: Params) {
  // 关键是这句
    Component().hitTestBehavior(HitTestMode.Transparent)
}

aboutToAppear(): void {
    let componentContent = new ComponentContent(
        this.context, wrapBuilder<[Params]>(builderOverlay),
        new Params(uiContext, {x:0, y: 100})
    );
    this.overlayManager.addComponentContent(componentContent, 0);
}

沉浸式

沉浸式页面开发常通过将应用页面延伸到状态栏和导航栏. 核心就是延伸,全屏和深色背景下的适配,以及挖孔区域适配

延伸方案:

  1. 使用Window.setWindowLayoutFullScreen()方法设置窗口为全屏模式。
  2. 设置组件的expandSafeArea属性,扩展组件的安全区域到状态栏和导航栏,从而实现沉浸式。
  • 这个方案只延伸当前组件,不会改变整体页面的页面,更常用,因为一般就是把背景色延伸一下而已

安全区域就是正常呈现内容的区域,其它为避让区,比如底部的bar,顶部的导航条,各种刘海和挖孔,岛等

避让:

  • 使用Window.setWindowSystemBarEnable()方法
    • Window.setSpecificSystemBarEnabled()方法设置状态栏和导航栏的显隐。
  • 使用Window.setWindowSystemBarProperties()方法设置状态栏和导航栏的样式。
  • 使用Window.getWindowAvoidArea()方法获取避让区域的高度,据此设置应用页面内容的上下padding实现避让状态栏和导航栏。
  • 使用Display.getCutoutInfo()方法获取挖孔区域宽高和位置信息,设置对应避让元素的margin实现挖孔区避让。

expandSafeArea

Tabs({ barPosition: BarPosition.End }) {
  // ...
}
.backgroundColor('#F1F3F5')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])

Window.setWindowLayoutFullScreen要复杂些,通过Window.getWindowAvoidArea()方法获取状态栏和导航栏高度,并用状态变量avoidArea记录。使用avoidAreaChange事件监听避让区域的变化,变化时更新状态变量avoidArea。

context = this.getUIContext()?.getHostContext() as common.UIAbilityContext;
private windowClass = this.context.windowStage.getMainWindowSync();
@State avoidArea: AvoidArea = { topRectHeight: 0, bottomRectHeight: 0 };

onShown() {
  this.windowClass.setWindowLayoutFullScreen(true);
  this.setAvoidArea()
  this.windowClass.on('avoidAreaChange', this.onAvoidAreaChange)
}

// ...

setAvoidArea() {
  // status bar area
  const statusBarArea = this.windowClass.getWindowAvoidArea(statusBarType);
  this.avoidArea.topRectHeight = statusBarArea.topRect.height;
  // navigation bar area
  const navBarArea = this.windowClass.getWindowAvoidArea(navBarType);
  this.avoidArea.bottomRectHeight = navBarArea.bottomRect.height;
}

onAvoidAreaChange = (data: window.AvoidAreaOptions) => {
  if (data.type === statusBarType) {
    this.avoidArea.topRectHeight = data.area.topRect.height;
  } else if (data.type === navBarType) {
    this.avoidArea.bottomRectHeight = data.area.bottomRect.height;
  }
}

// Tabs

Tabs({ barPosition: BarPosition.End }) {
  // ...
}
.backgroundColor('#F1F3F5')
.padding({
  top: this.avoidArea.topRectHeight + 'px',
  bottom: this.avoidArea.bottomRectHeight + 'px'
})

图片滚动到状态栏下面,但是返回按钮,菜单图标等不需要滚动: 方案: 只对swiper里的图片做expandSafeArea,对swiper不clip,使得图片可以滚动到swiper区别以外(不设置的话,图片会被状态栏挡住)

Swiper(this.swiperController) {
  ForEach(BANNER_IMAGES, (image: Resource) => {
    Image(image)
      .width('100%')
      .height('100%')
      .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP])
  }, (image: Resource) => JSON.stringify(image))
}
.clip(false)

让list expand到底部即可:

List() {
  // 列表
  ForEach(getFaqData(), (faq: Faq) => {
    ListItem() {
      this.FaqItem(faq)
    }
  }, (faq: Faq) => JSON.stringify(faq))
  // bottom
  ListItem() {
    Text($r('已加载全部'))
      .width('100%')
      .textAlign(TextAlign.Center)
      .opacity(0.6)
      .padding({ top: 10, bottom: 10 })
  }
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])

进入和退出全屏模式

// products/default/src/main/ets/pages/VideoPlayer.ets
context = this.getUIContext()?.getHostContext() as common.UIAbilityContext;
private windowClass = this.context.windowStage.getMainWindowSync();
// ...

onShown() {
  this.windowClass.setWindowLayoutFullScreen(true);
  this.windowClass.setWindowSystemBarEnable([]);
}

onHidden() {
  this.windowClass.setWindowLayoutFullScreen(false);
  this.windowClass.setWindowSystemBarEnable(['status', 'navigation']);
}

build() {
  NavDestination() {
    Column() {
      Video({ src: $rawfile('video.mp4') })
        // ...
    }
    .height('100%')
    .width('100%')
  }
  .hideTitleBar(true)
  .onShown(() => this.onShown())
  .onHidden(() => this.onHidden())
}

挖孔区避让

// 1. 设置全屏,打开沉浸模式
turnOnImmersive() {
  this.windowClass.setWindowLayoutFullScreen(true);
  this.windowClass.setWindowSystemBarEnable([]);
}

// 2. 通过Display.getCutoutInfo()方法获取挖孔区域的宽高和位置信息
async getCutoutInfo() {
  const displayClass = display.getDefaultDisplaySync();
  const res = await displayClass.getCutoutInfo();
  return res.boundingRects;
}

// 3. 计算目标元素的margin,保存变量, 然后更新目标元素的margin
async setStatusBarMargin(windowWidth: number) {
  const cutoutInfo = await this.getCutoutInfo();
  this.statusBarMargin = this.getStatusBarMargin(cutoutInfo, windowWidth);
}
getStatusBarMargin(cutoutInfo: display.Rect[], windowWidth: number): StatusBarMargin {
  if (!cutoutInfo || cutoutInfo.length === 0) {
    return { left: 0, right: 0 };
  }
  const cutoutRect = cutoutInfo[0];
  const cutoutLeftGap = cutoutRect.left;
  const cutoutWidth = cutoutRect.width;
  const cutoutRightGap = windowWidth - cutoutLeftGap - cutoutWidth;
  if (Math.abs(cutoutLeftGap - cutoutRightGap) <= 10) {
    return { left: 0, right: 0 };
  }
  if (cutoutLeftGap < cutoutRightGap) {
    return { left: cutoutLeftGap + cutoutWidth, right: 0 };
  }
  return { left: 0, right: cutoutRightGap + cutoutWidth };
}
...
Row() {
  Image($r('app.media.game_props'))
    .width(80)
    .margin({ left: this.statusBarMargin.left + 'px' })
  Blank()
  Image($r('app.media.game_pause'))
    .width(48)
    .margin({ right: this.statusBarMargin.right + 'px' })
}

深色背景延伸后,状态栏文字颜色变白:

onShown() {
  this.windowClass.setWindowSystemBarProperties({
    statusBarContentColor: '#FFFFFF'
  });
}

onHidden() {
  this.windowClass.setWindowSystemBarProperties({
    statusBarContentColor: '#000000'
  });
}

build() {
  NavDestination() {
    Column() {
      Image(this.getBackGroundImage())
        .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
        .objectFit(ImageFit.Fill)
    }
    .height('100%')
    .width('100%')
  }
  .hideTitleBar(true)
  .onShown(() => this.onShown())
  .onHidden(() => this.onHidden())
}
  • 设置expandSafeArea属性进行组件绘制扩展时,组件不能设置固定宽高尺寸。可尝试用百分比或padding改变组件宽高。
  • 设置expandSafeArea属性的组件需与安全区域边界重合。
  • 当设置expandSafeArea属性的组件的父组件是滚动类容器,需设置父组件的clip属性,不裁剪内部组件
  • Navigation组件和NavDestination组件默认扩展安全区域至状态栏和导航栏,相当于默认设置了expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])属性。
  • Tabs组件默认扩展安全区域至导航栏,相当于默认设置了expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])属性。
    • 那之前例子特意写Tabs扩展到bottom,是为了啥?

参考

深色模式

  • 系统组件会自动切换,内容适配主要依靠资源目录。当系统对应的设置项发生变化后(如深浅色模式等),应用会自动加载对应资源目录下的资源文件。(系统语言同理)
  • 系统为深色模式预留了dark目录,该目录在应用创建时默认不存在,在进行深色模式适配时,需要开发者在src/main/resources中手动创建出dark目录,将深色模式所需的资源放置到该目录下。对于浅色模式所需的资源,可以放入默认存在的src/main/resources/base目录下。
  • 在进行资源定义时,需要在base目录与dark目录中定义同名的资源。

切换方法:

  1. 开发者使用setColorMode()方法将ColorMode设置为COLOR_MODE_NOT_SET,就能跟随系统颜色模式
  2. 手动设置为COLOR_MODE_DARKCOLOR_MODE_LIGHT

获取当前的深浅色(及响应了系统深浅色切换后)并动态设置状态栏字体颜色。 方案1, 直接做两套资源文件,使用$r引用。 方案2,使用AppStorage存储状态,在页面中监听状态变化,动态设置状态栏字体颜色。

// 1. ability中获取状态,保存到app storage,供页面使用
export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    AppStorage.setOrCreate<ConfigurationConstant.ColorMode>('currentColorMode', this.context.config.colorMode);
    hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onCreate');
  }

  // ...

  onConfigurationUpdate(newConfig: Configuration): void {
    const currentColorMode: ConfigurationConstant.ColorMode | undefined = AppStorage.get('currentColorMode');
    if (currentColorMode !== newConfig.colorMode) {
      AppStorage.setOrCreate<ConfigurationConstant.ColorMode>('currentColorMode', newConfig.colorMode);
    }
  }
}

// UI
@Entry
@Component
struct Index {
  // ...
  @StorageProp('currentColorMode') @Watch('onCurrentColorModeChange') currentColorMode: ConfigurationConstant.ColorMode =
    ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET;
  private windowObj: window.Window | null = null;

  aboutToAppear(): void {
    window.getLastWindow(this.getUIContext().getHostContext(), (err: BusinessError, data) => {
      this.windowObj = data;
    })
  }

  onCurrentColorModeChange(): void {
    if (!this.windowObj) {
      return;
    }
    if (this.currentColorMode === ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT) {
      this.windowObj?.setWindowSystemBarProperties({
        statusBarContentColor: '#000000'
      })
    } else if (this.currentColorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK) {
      this.windowObj?.setWindowSystemBarProperties({
        statusBarContentColor: '#FFFFFF'
      })
    }
  }

  // ...

  build() {
    // ...
  }
}

ArkUI优化

渲染流程:

  1. 根据UI描述创建后端页面节点树
  • 包含属性更新,布局测算,事件处理等
  • 测算是确定宽高
  • 布局是确定四个顶点(其实有左上角和宽高就能确定了)
  1. 根据节点树生成界面描述数据结构--渲染树
  • RenderNode组成
  • 包含大小,位置等属性
  1. 渲染线程执行绘制工作
  2. 11.1mm左右必须完成绘制,否则丢帧

减小节点数,或简化布局计算(尽量给定组件宽高)都能加快渲染 直接描述位置比用row, column一层层包裹确实更高效,横竖堆叠只是让逻辑更清楚,其实是让渲染变得更复杂,iOS的UIStackView也会存在这样的问题,直接AutoLayout也会效率高些

使用ifvisibility要重得多,因为切换的时候是在创建新的组件,重新进行了测算和布局,而visibility的组件一直挂在组件树上

缓存列表项关键代码

@Component
Struct MyComponent {
  build () {
    list() {
      //…
      LazyForEach()
    }.cachedCount(n/2)
  }
}

组件复用关键代码

@Component
@Reusable // 1. 增加@Reusable注解
Export struct ArticleCardView {
  @Prop isCollected: boolean = false;
  @Prop isLiked: boolean = false;
  @Prop articleItem: LearningResource = new LearningResource();
  onCollected?: () => void;
  onLiked?: () => void;

  // 2. 增加刷新UI入口
  aboutToReuse(params) {
     this.onCollected = params.onCollected;
     this.onLiked = params.onLiked;
  }

  build() {
    ......
  }
}
@Component
export struct DiscoverView {
  private data: ArticleListData = new ArticleListData();
  ......
  build() {
    List() {
      Column() {
        LazyForEach(this.data, (item: LearningResource) => {
          ListItem() {
            Column() {
              ArticleCardView({
                articleItem: item
                ......
              })
            }
          }
          .reuseId(‘ArticleCardView') // 3. 增加复用ID
        }, (item: LearningResource) => item.id)
      }
    }.cachedCount(3);
  }
}

Web组件

参考Web组件

通讯录

import { contact } from '@kit.ContactsKit';
import { deviceInfo } from '@kit.BasicServicesKit';

chooseContact(): Promise<string> {
  let phone:string = '';
  let name:string = '';
  return new Promise((resolve) => {
    if (deviceInfo.productModel === 'emulator') {
      return resolve('13800000000_test');
    }
    let promise = contact.selectContacts(); // 就这么简单?!
    promise.then((info: Array<contact.Contact>) => {
      info.forEach((item: contact.Contact) => {
        phone = item?.phoneNumbers ? item?.phoneNumbers[0].phoneNumber : '';
        name = item?.name ? item?.name?.fullName : '';
      })
      resolve(phone + '_' + name);
    }).catch((err: object | string) => {
      hilog.error(0xFF00, 'SelectContact', '%{public}s', `selectContact fail: err->${JSON.stringify(err)}`);
    });
  })
}

通知

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

文本通知

设置NOTIFICATION_CONTENT_BASIC_TEXT类型,过程也不简洁,要构造一个request再publish:

publishNotification() { 
  let notificationRequest: notificationManager.NotificationRequest = { // 描述通知的请求 
    id: 1, // 通知ID  
    content: { // 通知内容 
      notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, // 普通文本类型通知 
      normal: { // 基本类型通知内容 
        title: '通知内容标题', 
        text: '通知内容详情' 
      } 
    } 
  } 
  notificationManager.publish(notificationRequest).then(() => { // 发布通知 
    console.info('publish success'); 
  }).catch((err: Error) => { 
    console.error(`publish failed,message is ${err}`); 
  }); 
} 

进度条通知

在发布进度类型通知前需要查询系统是否支持进度条模板。

notificationManager.isSupportTemplate('downloadTemplate').then(isSupport => {
  if (!isSupport) {
    this.getUIContext().getPromptAction().showToast({
      message: $r('app.string.invalid_button_toast')
    })
  }
  this.isSupport = isSupport;
});

然后再用downloadTemplate这个模板填充数据:

let template:notificationManager.NotificationTemplate = {
  name: 'downloadTemplate',
  data: {
    title:  `${title}`,
    fileName:  `${title}${CommonConstants.DOWNLOAD_FILE}`,
    progressValue: progress,
    progressMaxValue: CommonConstants.PROGRESS_TOTAL,
    isProgressIndeterminate: false
  }
};

发布的代码,用template创建一个request再publish:

let notificationRequest: notificationManager.NotificationRequest = {
  id: CommonConstants.NOTIFICATION_ID,
  notificationSlotType: notificationManager.SlotType.CONTENT_INFORMATION,
  // Construct a progress bar template. The name field must be set to downloadTemplate.
  template: template,
  content: {
    notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
    normal: {
      title: `${title}${CommonConstants.DOWNLOAD_FILE}`,
      text: ' ',
      additionalText: `${progress}%`
    }
  },
  wantAgent: wantAgentObj
};
notificationManager.publish(notificationRequest).catch((err: Error) => {
  Logger.error(`[ANS] publish failed,message is ${err}`);
});

注意区分template里的data和request里的content, 一个用于绘制进度条,一个用于提供其它文本信息. 同时,相比于普通文本消息,也就多了一个template字段和notificationSlotType字段.

slotType好像是几种预设的类型,控制通知出现在哪些位置和提示音

  • SlotType.SOCIAL_COMMUNICATION:社交通信,状态栏中显示通知图标,有横幅,有提示音。
  • SlotType.SERVICE_INFORMATION:服务提醒,状态栏中显示通知图标,有横幅,有提示音。
  • SlotType.CONTENT_INFORMATION:内容资讯,状态栏中不显示通知图标,无横幅,无提示音。
  • SlotType.OTHER_TYPES:其它类型,状态栏中不显示通知图标,无横幅,无提示音。

更新通知

在发出通知后,使用您之前使用的相同通知ID,再次调用notificationManager.publish来实现通知的更新。如果之前的通知是关闭的,将会创建新通知。

移除通知

  • 通过通知ID和通知标签取消已发布的通知notificationManager.cancel(notificationId)
  • 取消所有已发布的通知notificationManager.cancelAll()

通知组

let notifyId = 0; 
 
let chatRequest: notificationManager.NotificationRequest = {  
  id: notifyId++, 
  groupName:'ChatGroup', 
  content: { 
    //... 
   } 
 }; 
 
let productRequest: notificationManager.NotificationRequest = {  
  id: notifyId++, 
  groupName: 'ProductGroup', 
  content: { 
    //... 
   } 
 };

同组通知可以折叠,比如聊天通知和商品推荐

行为意图

应用向Ability Kit申请WantAgent,并将WantAgent封装至通知中。当发布通知时,用户便可以通过点击通知栏中的消息或按钮,拉起目标应用组件或发布公共事件。

点通知几乎都是为了跳应用(鸿蒙叫拉起Ability),所以这一节省略掉的其实很重要

具体查看

// NotificationUtil.ets
import { wantAgent } from '@kit.AbilityKit';
import { notificationManager } from '@kit.NotificationKit';
import CommonConstants from '../constants/CommonConstants';
import Logger from '../utils/Logger';
import { BusinessError } from '@kit.BasicServicesKit';

// Obtaining the Want Information of an App.
export function createWantAgent(bundleName: string, abilityName: string): Promise<object> {
  let wantAgentInfo = {
    wants: [
      {
        bundleName: bundleName,
        abilityName: abilityName
      }
    ],
    operationType: wantAgent.OperationType.START_ABILITY,
    requestCode: 0,
    wantAgentFlags: [wantAgent.WantAgentFlags.CONSTANT_FLAG]
  } as wantAgent.WantAgentInfo;
  return wantAgent.getWantAgent(wantAgentInfo);
}

// Publish a notification.
export function publishNotification(progress: number, title: string, wantAgentObj: object) {
  // Construct a notification template object.
  let template:notificationManager.NotificationTemplate = {
    name: 'downloadTemplate',
    data: {
      progressValue: progress,
      progressMaxValue: CommonConstants.PROGRESS_TOTAL,
      isProgressIndeterminate: false
    }
  };
  let notificationRequest: notificationManager.NotificationRequest = {
    id: CommonConstants.NOTIFICATION_ID,
    notificationSlotType: notificationManager.SlotType.CONTENT_INFORMATION,
    // Template object.
    template: template,
    content: {
      notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
      normal: {
        title: `${title}${CommonConstants.DOWNLOAD_FILE}`,
        text: ' ',
        additionalText: `${progress}%`
      }
    },
    // 有了这个就知道跳转了
    wantAgent: wantAgentObj
  };
  notificationManager.publish(notificationRequest).catch((err: BusinessError) => {
    Logger.error(`[ANS] publish failed, code is ${err.code}, message is ${err.message}`);
  });
}

使用

aboutToAppear() {
  openNotificationPermission(this.getUIContext().getHostContext() as common.UIAbilityContext);
  let bundleName = this.context.abilityInfo.bundleName;
  let abilityName = this.context.abilityInfo.name;
  createWantAgent(bundleName, abilityName).then(want => {
    this.wantAgentObj = want;
  }).catch((err: BusinessError) => {
    Logger.error(`getWantAgent fail, code is ${err.code}, message is ${err.message}`);
  });
}
// 发出通知
publishNotification(this.downloadProgress, this.notificationTitle, this.wantAgentObj);

Native/Node-Api

Node-API规范封装了I/O、CPU密集型、OS底层等能力并对外暴露C接口,使用C/C++模块的注册机制,向ArkTS/JS对象上挂载属性和方法的方式来实现ArkTS/JS和C/C++的交互。主要场景如下:

  • 系统可以将框架层丰富的模块功能通过Node-API的模块注册机制对外暴露ArkTS/JS的接口,将C/C++的能力开放给应用的ArkTS/JS层。
  • 应用开发者也可以选择将一些对性能、底层系统调用有要求的核心功能用C/C++封装实现,再通过ArkTS/JS接口使用,提高应用本身的执行效率。

ArkTS和C++之间的交互流程,主要分为以下两步:

  • 初始化阶段:当ArkTS侧在import一个Native模块时,ArkTS引擎会调用ModuleManager加载模块对应的so及其依赖。
    • 首次加载时会触发模块的注册,将模块定义的方法属性挂载到exports对象上并返回该对象。
  • 调用阶段:当ArkTS侧通过上述import返回的对象调用方法时,ArkTS引擎会找到并调用对应的C/C++方法。

napi_env与ArkTS/JS线程的上下文环境绑定,每一个napi_env都持有独立的运行时上下文环境,当ArkTS/JS线程退出之后,相应的napi_env将不再有效。

更多

三方库

文档

添加本地库:

  • File > New > Module (或工程根目录右键 > New > Module)
  • 在Choose Your Ability Template界面中,选择Static Library
  • 引用方式有以下几种:
    • ohpm install ../library --save
    • 或者编辑entry/oh-package.json5,添加文件目录为依赖
  • 使用: import { Buttons } from '@ohos/library';
"dependencies": {
  "@ohos/library": "file:../library"
}