/*
* Copyright (c) Huawei Device Co., Ltd. 2024-2025. All rights reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LogDomain, LogHelper } from '@ohos/basicutils/src/main/ets/TsIndex';
import {
DeviceInfoVm,
INotificationMenuVm,
INotificationSwipeVm,
INotificationListVm,
NotificationItemBaseVm,
INotificationCardVm,
AnimationBase,
ResourceVm,
DropdownVm,
DropDownPanelManager,
} from '@ohos/systemuicommon/newIndex';
import { NotificationApiUtil } from '../utils/NotificationApiUtil';
import { curves } from '@kit.ArkUI';
import { NotificationNormalVm } from './NotificationNormalVm';
import {
NormalNotification,
NormalNotificationGroup,
NotificationBase, NotificationCategory, SystemUIUseScene } from '@ohos/systemuicommon/newTsIndex';
import { AccessibilityVm } from '@ohos/systemuicommon/src/main/ets/vm/AccessibilityVm';
import { DropDownEvent } from '@ohos/frameworkwrapper';
import { NotificationSysEventReporter, VibratorUtil } from '@ohos/systemuicommon';
const TAG = 'NotificationSwipeVm';
const log = LogHelper.getLogHelper(LogDomain.NC, TAG);
const CLEAR_BUTTON_ID = 'NotificationClearButtonView_button';
const EXTRA_NTF_TITLE_ID = 'NotificationMoreHeaderView_Title_Extra_Ntf';
const LIST_EMPTY_ID = 'NotificationListView_empty';
@ObservedV2
export class NotificationSwipeVm extends NotificationItemBaseVm implements INotificationSwipeVm {
protected static instances: Map<string, NotificationSwipeVm> = new Map();
/**
* 触发手势速度滑动临界值
*/
protected static readonly SWIPE_SPEED = 750;
/**
* 触发手势横滑的临界值
*/
protected static readonly SWIPE_DISTANCE = 5;
/**
* 松手时的动画曲线
*/
protected static readonly SWIPE_ANIMATION_CURVES = curves.springCurve(0, 1, 228, 23);
/**
* 从右向左滑动,展开或关闭的临界值
*/
protected static readonly SWIPE_LEFT_CRITICAL = 12;
/**
* 从左向右滑动清除通知的阈值
*/
protected static readonly SWIPE_RIGHT_PERCENT = 1.0 / 2;
/**
* 阻尼系数,平移值*系数
*/
protected static readonly SWIPE_DAMP = 0.4;
/**
* 左横滑动态跟手率系数
*/
protected static readonly DYNAMIC_FOLLOW_FACTOR = -1.848
/**
* 从右向左长滑动清除通知的阈值
*/
protected static readonly SWIPE_LEFT_DELETE_PERCENT = 0.67;
/**
* 横向偏移量
*/
@Trace public offsetX: number = 0;
/**
* 是否需要振动
*/
protected needVibrate: boolean = true;
/**
* 开始滑动时的横向偏移量
*/
protected startOffsetX: number = 0;
/**
* 是否为分组中的条目
*/
private isInGroup: boolean = false;
/**
* 条目宽度
*/
@Computed get itemWidth(): number {
return this.vmInjector.getPanelVm().cardWidth;
}
/**
* 卡片删除偏移
*/
@Computed get deleteOffset(): number {
return this.itemWidth;
}
/**
* 通知中心面板触摸Y轴点,-1无触摸
*/
@Computed get panelDownWindowY(): number {
return this.vmInjector.getPanelVm()?.panelDownWindowY ?? -1;
}
/**
* 通知列表是否滑动中
*/
@Computed get listIsScrolling(): boolean {
return this.vmInjector.getListScrollerVm()?.isScrolling ?? false;
}
/**
* 通知列表是否正在一键清除
*/
@Computed get isClearingAll(): boolean {
return this.vmInjector.getClearButtonVm()?.isClearingAll ?? false;
}
protected dropdownEvent: DropDownEvent = DropdownVm.instance.dropdownEvent;
/**
* 菜单宽度
*/
protected menuWidth: number = 0;
/**
* 上一次滑动位置
*/
protected lastOffsetX: number = 0;
/**
* 是否从左向右横滑
*/
protected isLeftToRight: boolean = false;
/**
* 是否正在删除
*/
protected isSwipeRemoving: boolean = false;
/**
* 展开收起动效实例
*/
protected swipeAnimation = new AnimationBase(AnimationBase.NAMES.NTF_CARD_SWIPE_ANIMATION, true);
protected cardVm?: INotificationCardVm = this.vmInjector.getCardVm?.(this.ntf);
protected menuVm?: INotificationMenuVm = this.vmInjector.getMenuVm?.(this.ntf);
protected listVm?: INotificationListVm = this.vmInjector.getListVm?.();
init(): void {
NotificationSwipeVm.instances.set(this.getKey(), this);
log.showInfo(`init swipe vm for ${this.getKey()}, isRemoveAllowed: ${this.ntf.isRemoveAllowed}`);
}
destroy(): void {
log.showInfo(`destroy swipe vm for ${this.getKey()}, reminging size: ${NotificationSwipeVm.instances.size}`);
NotificationSwipeVm.instances.delete(this.getKey());
}
@Monitor('offsetX')
onOffsetXChange(monitor: IMonitor) {
this.menuVm?.updateSwipeOffsetX(monitor.value()?.now as number, this.isSwipeRemoving);
}
@Monitor('panelDownWindowY', 'listIsScrolling', 'dropdownEvent.moveY', 'dropdownEvent.progress', 'isClearingAll')
onSwipeResetEvent() {
if (this.checkNeedReset()) {
this.update(0, true);
}
}
/**
* 检测是否需要重置通知卡片,该函数供子类重写
*/
protected checkNeedReset(): boolean {
if (this.offsetX === 0 || this.isSwipeRemoving) {
return false;
}
// 当前四种场景reset通知卡片:
// 1. 卡片未touch状态, 此时触摸点变化
if (this.panelDownWindowY !== -1 && !this.cardVm?.isTouching) {
return true;
}
// 2. 列表列表滚动场景
if (this.listIsScrolling) {
return true;
}
// 3. onBackPress场景
if (this.dropdownEvent.progress === 0 && this.dropdownEvent.moveY === 0) {
return true;
}
// 4. 一键清除场景
if (this.isClearingAll) {
return true;
}
return false;
}
public static reset(exceptInstance?: NotificationSwipeVm, animation: boolean = true): void {
for (const instance of NotificationSwipeVm.instances.values()) {
if (instance.offsetX === 0 || exceptInstance === instance || instance.isSwipeRemoving) {
continue;
}
log.showInfo(`Reset ${instance.ntf?.hashCode}, animation:${animation}`);
instance.update(0, animation);
}
}
/**
* 左滑阈值:列表场景为菜单宽度
* @param itemWidth
*/
public setLeftThreshold(threshold: number): void {
this.menuWidth = threshold;
}
/**
* 右滑阈值比例,列表场景固定
* @param itemWidth
*/
public setRightThreshold(threshold: number): void {}
/**
* 获取横向偏移量
* @returns
*/
public getOffsetX(): number {
return this.offsetX;
}
/**
* 重置偏移
*/
public resetOffsetX(isResetCurrent?: boolean): void {
log.showInfo(`Reset offsetX of ${this.ntf?.hashCode}`);
if (isResetCurrent) {
this.update(0, true);
return;
}
NotificationSwipeVm.reset(this);
}
/**
* 删除通知滑动
* @returns
*/
public removeNtf(): void {
log.showInfo(`Remove ntf for ${this.ntf?.hashCode}`);
this.remove(-this.itemWidth, Math.abs(-this.itemWidth - this.offsetX));
}
/**
* 左长滑/右长滑删除通知打点
* @returns
*/
private reportNtfSlipLeftRight() {
NotificationSysEventReporter.notificationSlipLeftRight({
CREATOR_BUNDLE_NAME: this.ntf?.creatorBundleName,
NOTIFICATION_ID: this.ntf?.id,
NOTIFICATION_SLOT_TYPE: this.ntf?.slotType,
DISPLAY_SCENE: this.ntf ? 3 : 4,
TIME_STAMP: `${DropDownPanelManager.getLastDropdownTime()}`,
IS_GROUP: this.ntf?.isNormalGroup() ? 1 : this.isInGroup ? 2 : 0,
IS_AGGREGATE: 0,
NOTIFICATION_CONTROL_FLAGS: this.ntf?.controlConfig?.flags,
BUNDLE_TYPE: this.ntf?.bundleType,
TRACE_ID: this.ntf?.traceId
});
}
protected update(endOffsetX: number, animation: boolean): void {
if (this.offsetX === endOffsetX) {
return;
}
if (animation) {
this.swipeAnimation.executeAnimation([
{
curve: NotificationSwipeVm.SWIPE_ANIMATION_CURVES,
event: () => {
this.offsetX = endOffsetX;
}
}
]);
} else {
this.offsetX = endOffsetX;
}
}
protected async remove(endOffsetX: number, duration: number): Promise<void> {
if (this.isSwipeRemoving) {
return;
}
log.showInfo(`Swipe remove start, endOffsetX: ${endOffsetX}, duration: ${duration}, stack: ${new Error().stack}`);
AccessibilityVm.instance.sendEventByResource($r('app.string.cc_accessibility_str_ntf_swipe_remove_text'));
// 更多通知折叠删除无左滑动效,与旧版本一致
if (this.isMoreBrief()) {
NotificationApiUtil.removeNtfByCancel(this.listVm?.moreNtfList || []);
AccessibilityVm.instance.requestFocusAccessibility(this.getRemoveAccessibilityId(this.ntf), true);
return;
}
this.isSwipeRemoving = true;
await new AnimationBase(AnimationBase.NAMES.NTF_CARD_SWIPE_ANIMATION, true).executeAnimation([{
duration: duration,
curve: Curve.Smooth,
event: () => {
this.offsetX = endOffsetX * this.getExpandDistance();
},
}]);
NotificationApiUtil.removeNtfByCancel([this.ntf]);
const isInGroup: boolean = (this.listVm?.getGroupNtf(this.ntf) !== undefined) && !this.ntf.isNormalGroup();
if (isInGroup) {
AccessibilityVm.instance.requestFocusAccessibility(this.getInGroupRemoveAccessibilityId(), true);
} else if (!this.ntf.isLiveView()) {
// 实况横滑删除这里不处理,在NotificationListVm中处理,后续迁移
AccessibilityVm.instance.requestFocusAccessibility(this.getRemoveAccessibilityId(this.ntf), true);
}
this.isSwipeRemoving = false;
}
/**
* 获取横滑手势处理器
* @returns
*/
public getPanGestureHandler(): PanGestureHandler {
return new PanGestureHandler({
direction: PanDirection.Horizontal,
distance: NotificationSwipeVm.SWIPE_DISTANCE
})
.onActionStart((event: GestureEvent) => this.handleActionStart(event))
.onActionUpdate((event: GestureEvent) => this.handleActionUpdate(event))
.onActionEnd((event: GestureEvent) => this.handleActionEnd(event))
.onActionCancel(() => this.handleActionCancel());
}
protected handleActionStart(event: GestureEvent): void {
log.showInfo(`handleActionStart: ${event?.axisVertical}`);
this.resetOffsetX();
this.startOffsetX = this.offsetX;
this.lastOffsetX = this.offsetX;
NotificationNormalVm.hideActionButtons(this.vmInjector.scene);
log.showInfo(`Start swipe ${this.ntf?.hashCode}, startTranslateX: ${this.startOffsetX}`);
}
protected handleActionUpdate(event: GestureEvent): void {
const offsetX = ResourceVm.instance.getXDirectionValue(event.offsetX);
this.update(this.getRealOffsetX(offsetX), false);
this.overRemoveThresholdVibration();
}
protected handleActionEnd(event: GestureEvent): void {
log.info(`handleActionEnd:`, event);
const offsetX = ResourceVm.instance.getXDirectionValue(event.offsetX);
this.isLeftToRight = offsetX > 0;
// 触发滑动删除
if (event.axisVertical === 0 && event.velocity >= NotificationSwipeVm.SWIPE_SPEED && this.handleSwipeSpeed(event)) {
return;
}
this.stop(this.getRealOffsetX(offsetX), event.velocity);
}
protected handleActionCancel(): void {
log.info(`handleActionCancel:`);
this.stop(0);
}
protected stop(offsetX: number, velocity?: number): void {
log.showInfo(`Stop swipe ${this.ntf?.hashCode} offsetX: ${offsetX}, menuWidth: ${this.menuWidth}` +
`, velocity:${velocity}`);
this.needVibrate = true;
if (this.isLeftToRight) {
this.updateLeftToRight(offsetX, velocity);
} else {
this.updateRightToLeft(offsetX, 1, velocity);
}
}
protected updateRightToLeft(offsetX: number, expandTranX: number = 1, velocity?: number): void {
log.showInfo(`updateRightToLeft, offsetX: ${offsetX}, isRemoveAllowed: ${this.isRemoveAllowed()}`);
if (-offsetX < (this.getRemoveOffsetXThreshold() + this.menuWidth)) {
// 左滑小于临界值,展示删除和设置按钮打点
this.reportSlipLeft(this.ntf)
}
let endOffsetX = 0;
if (-offsetX >= (this.getRemoveOffsetXThreshold() + this.menuWidth) && this.isRemoveAllowed()) {
// 左滑超过删除临界值,删除通知
endOffsetX = -this.deleteOffset * expandTranX;
this.remove(endOffsetX, this.getRemoveDuration(endOffsetX, offsetX, velocity));
this.reportNtfSlipLeftRight()
return;
}
// 菜单展开状态
if (-this.startOffsetX >= this.menuWidth) {
// 滑动位置小于菜单宽度则位置恢复,否则回到原来位置
endOffsetX = -offsetX < this.menuWidth ? 0 : this.startOffsetX;
} else {
// 菜单收起状态
if (offsetX <= -NotificationSwipeVm.SWIPE_LEFT_CRITICAL) {
// 左滑超过菜单展开临界值,则滑到菜单宽度的位置
endOffsetX = -this.menuWidth;
AccessibilityVm.instance.sendEventByResource($r('app.string.cc_accessibility_str_ntf_swipe_left_text'),
$r('app.string.cc_accessibility_str_ntf_collapse_tips'));
}
}
this.update(endOffsetX, true);
}
/**
* 左滑展示设置和删除按钮打点
* @returns
*/
private reportSlipLeft(ntf: NotificationBase): void {
NotificationSysEventReporter.notificationSlipLeft({
CREATOR_BUNDLE_NAME: ntf?.creatorBundleName,
NOTIFICATION_SLOT_TYPE: ntf?.slotType,
TIME_STAMP: `${DropDownPanelManager.getLastDropdownTime()}`,
NOTIFICATION_ID: ntf?.id,
NOTIFICATION_CONTROL_FLAGS: ntf?.controlConfig?.flags,
BUNDLE_TYPE: ntf?.bundleType,
TRACE_ID: ntf?.traceId
});
}
protected updateLeftToRight(offsetX: number, velocity?: number): void {
let endOffsetX = 0;
// 非展开菜单状态且可删除,右滑超过临界值时删除,否则位置恢复
if (offsetX >= NotificationSwipeVm.SWIPE_RIGHT_PERCENT * this.itemWidth && this.isRemoveAllowed() &&
-this.startOffsetX <= this.menuWidth) {
endOffsetX = this.deleteOffset;
this.remove(endOffsetX, this.getRemoveDuration(endOffsetX, offsetX, velocity));
this.reportNtfSlipLeftRight()
return;
} else {
endOffsetX = 0;
AccessibilityVm.instance.sendEventByResource($r('app.string.cc_accessibility_str_ntf_swipe_right_text'));
}
this.update(endOffsetX, true);
}
protected handleSwipeSpeed(event: GestureEvent): boolean {
log.showInfo(`handleSwipeSpeed direction: ${this.isLeftToRight}` +
`, isRemoveAllowed: ${this.isRemoveAllowed()}, speed: ${event.velocity}`);
NotificationNormalVm.hideActionButtons(this.vmInjector.scene);
if (!this.isRemoveAllowed()) {
return false;
}
// 从左向右滑,并且通知可清除,菜单未展开,直接清除 或者 从右往左滑,如果是菜单展开状态则删除
const isRemove = (this.isLeftToRight && (-this.startOffsetX < this.menuWidth || this.menuWidth === 0)) ||
(!this.isLeftToRight && this.startOffsetX <= -this.menuWidth);
if (!isRemove) {
return false;
}
const duration = Math.abs(this.itemWidth - Math.abs(this.offsetX)) / event.velocity * 1000;
this.remove(this.isLeftToRight ? this.itemWidth : -this.itemWidth, duration);
return true;
}
protected getRealOffsetX(swipeOffsetX: number): number {
// 原始滑动偏移位置
const originalOffsetX = swipeOffsetX + this.startOffsetX;
// 计算后真实的偏移位置
let realOffsetX = originalOffsetX;
// 右滑
if ((this.startOffsetX > -this.menuWidth && swipeOffsetX > 0) || this.itemWidth === 0) {
// 能够清除无阻尼,不能清除加阻尼
realOffsetX = this.isRemoveAllowed() ? originalOffsetX : originalOffsetX * NotificationSwipeVm.SWIPE_DAMP;
} else if (this.itemWidth !== 0 && this.menuWidth > 0) {
// 超出菜单偏移
const overMenuOffsetX = Math.abs(originalOffsetX) - this.menuWidth;
// 超出menu宽度的偏移量需要加阻尼,使用动态阻尼率
if (overMenuOffsetX > 0) {
const dynamicFollowRate =
Math.exp(NotificationSwipeVm.DYNAMIC_FOLLOW_FACTOR * overMenuOffsetX *
NotificationSwipeVm.SWIPE_LEFT_DELETE_PERCENT / this.itemWidth);
realOffsetX = dynamicFollowRate * (originalOffsetX - this.lastOffsetX) + this.offsetX;
}
}
this.lastOffsetX = originalOffsetX;
return realOffsetX;
}
protected getKey(): string {
return this.vmInjector.getId(`${TAG}_${this.ntf?.hashCode}`);
}
protected getRemoveOffsetXThreshold(): number {
return DeviceInfoVm.instance.isLandscape ? 220 : 100;
}
protected isMoreBrief(): boolean {
const isCollapsed = this.vmInjector.getNotificationMoreHeaderVm()?.isCollapsed ?? false;
return this.ntf.isMoreNtf() && isCollapsed;
}
/**
* 是否允许横滑删除
* @returns
*/
protected isRemoveAllowed(): boolean {
if (this.isMoreBrief() || this.ntf.isLiveView() || this.ntf.isOngoing) {
return false;
}
return this.ntf?.isRemoveAllowed;
}
/**
* 超过删除通知阈值触发振动
*/
protected overRemoveThresholdVibration(): void {
// 横幅通知横滑不振动
if (this.vmInjector.scene === SystemUIUseScene.BANNER) {
return;
}
const isDeleteThreshold: boolean = -this.offsetX - this.menuWidth >= this.getRemoveOffsetXThreshold();
if (isDeleteThreshold && this.needVibrate && this.isRemoveAllowed()) {
this.needVibrate = false;
VibratorUtil.startVibration('haptic.drag', 'physicalFeedback');
} else if (!isDeleteThreshold) {
this.needVibrate = true;
}
}
public getSwipeAccessibilityDescription(): string {
if (this.offsetX < 0) {
return ResourceVm.instance.getString($r('app.string.cc_accessibility_str_ntf_collapse_tips'));
}
if (!this.ntf.wantAgent && !this.ntf.isNormalGroup()) {
return ResourceVm.instance.getString($r('app.string.cc_accessibility_str_ntf_focused_action_tips'));
}
return [ResourceVm.instance.getString($r('app.string.cc_accessibility_str_click')),
ResourceVm.instance.getString($r('app.string.cc_accessibility_str_ntf_focused_action_tips'))].join(' ');
}
protected getExpandDistance(): number {
return 1;
}
/**
* 通知列表中删除通知,获取下一个焦点id
*
* @param removeNtf 被删除的通知数据
* @returns id 下一个焦点id
*/
getRemoveAccessibilityId(removeNtf: NotificationBase): string {
if (!AccessibilityVm.instance.isEnabled) {
return '';
}
if (!this.listVm) {
return '';
}
const isMore: boolean = removeNtf.isMoreNtf();
const targetList = isMore ? this.listVm.moreNtfList : this.listVm.mainNtfList;
const otherList = isMore ? this.listVm.mainNtfList : this.listVm.moreNtfList;
const curIndex: number = targetList.findIndex((ntf) => {
return ntf.hashCode === removeNtf.hashCode;
});
if (targetList.length === 1 && otherList.length > 0) {
return isMore ? CLEAR_BUTTON_ID : EXTRA_NTF_TITLE_ID;
}
if (targetList.length === 1 && otherList.length <= 0) {
return LIST_EMPTY_ID;
}
if (curIndex === targetList.length - 1) {
return this.vmInjector.getCardVm(targetList[curIndex - 1]).getAccessibilityFocusId();
}
return this.vmInjector.getCardVm(targetList[curIndex + 1]).getAccessibilityFocusId();
}
/**
* 删除子通知,获取下一个焦点id
*
* @returns id 下一个焦点id
*/
private getInGroupRemoveAccessibilityId(): string {
if (!AccessibilityVm.instance.isEnabled) {
return '';
}
if (!this.listVm) {
return '';
}
const groupNtf: NormalNotificationGroup | undefined = this.listVm.getGroupNtf(this.ntf);
if (!groupNtf) {
return '';
}
const children: NormalNotification[] = groupNtf.children;
const curIndex: number = children.findIndex((ntf) => {
return ntf.hashCode === this.ntf.hashCode;
});
if (children.length === 1) {
// 组通知被删除,聚焦通知列表中下一条通知
return this.getRemoveAccessibilityId(groupNtf);
}
if (curIndex === children.length - 1) {
// 最后一条子通知,聚焦前一条子通知
return this.vmInjector.getCardVm(children[curIndex - 1]).getAccessibilityFocusId();
}
// 非最后一条子通知,聚焦后一条子通知
return this.vmInjector.getCardVm(children[curIndex + 1]).getAccessibilityFocusId();
}
private getRemoveDuration(endOffsetX: number, offsetX: number, velocity?: number): number {
if (velocity && velocity >= NotificationSwipeVm.SWIPE_SPEED) {
return Math.abs(endOffsetX - offsetX) / velocity * 1000;
}
return Math.abs(endOffsetX - offsetX);
}
}