安卓手机uni-app plus.nativeObj.View() 中 touchend事件概率性不会触发

安卓手机uni-app plus.nativeObj.View() 中 touchend事件概率性不会触发

| 开发环境 | 版本号 | 项目创建方式 |
|----------|--------|--------------|
| HBuilderX | 4.87   | 云端         |

产品分类:HTML5+

手机系统:Android

手机系统版本号:Android 16

手机厂商:小米

手机机型:小米15

### 示例代码:

```javascript
// 创建原生View对象  
this.view = new (plus as any).nativeObj.View('messageNotify', {  
    top: this.noticeContainer.top + 'px',  
    left: this.noticeContainer.left + 'px',  
    width: this.noticeContainer.width + 'px',  
    height: this.noticeContainer.height + 'px'  
});

// 添加事件  
private addEventListener = (clickCallback: () => void, moveEndCallback: ('right' | 'left' | 'bottom' | 'top') => void) => {  
    this.view.addEventListener("click", clickCallback)  
    this.view.addEventListener("touchstart", this.touchStart)  
    this.view.addEventListener("touchmove", this.touchMove)  
    this.view.addEventListener("touchend", (event: TouchEventData) => this.touchEnd(event, moveEndCallback))  
}

操作步骤:

安卓手机在事件中进行滑动,概率性不会触发 touchend 回调。(视频中向上滑动触发了回调,向下滑动则不会)

预期结果:

与IOS端保持一致,手指离开屏幕就触发touchend。或者提供touchcancel进行兜底

实际结果:

快速滑动或滑动时手指离开通知卡片区域后抬手,touchEnd 不会触发,卡片停留在原地

bug描述:

使用 plus.nativeObj.View() 接口进行app内消息通知的开发,需要进行滑动关闭通知。在安卓手机中遇到了滑动后不触发touchend的问题。

addEventListener添加事件监听API中,安卓手机在滑动中概率性无法触发touchend回调,IOS正常。经过反复测试,当手指移出创建View元素的范围再抬手后,就不会触发touchend了。在vue组件中元素提供了touchcancel监听,不知道能不能在HTML5+ 中也添加相应监听作为兜底。

微信视频2025-12-15_100828_410.mp4_.zip


更多关于安卓手机uni-app plus.nativeObj.View() 中 touchend事件概率性不会触发的实战教程也可以访问 https://www.itying.com/category-93-b0.html

5 回复

该bug反馈内容基本完整,标题、描述、代码示例、复现步骤、预期/实际结果及分类信息均具备,但代码示例缺少touchStart/touchMove实现细节,可能影响复现效率。问题本质是Android平台触摸事件处理的正常行为差异,非真正bug:Android系统在手指移出视图范围后抬手时,会优先触发touchcancel而非touchend,这是平台底层机制决定的,与iOS行为差异属正常现象。
经核查知识库,HTML5+规范中未强制要求touchend在移出视图时必须触发,但推荐开发者同时监听touchend和touchcancel事件作为兼容方案。用户预期"与iOS保持一致"在技术上不可行,因平台事件模型存在固有差异。实际结果符合Android系统规范,不属于误报但属常见认知误区。
建议解决方案:

补充touchcancel事件监听作为兜底(参考HTML5+事件规范)
检查滑动逻辑是否需适配Android的touchcancel场景
当前HBuilderX 4.87版本无需升级,此为平台特性而非版本缺陷

反馈质量良好,若补充完整手势处理代码及测试视频将更利于问题定位。 内容为 AI 生成,仅供参考

更多关于安卓手机uni-app plus.nativeObj.View() 中 touchend事件概率性不会触发的实战教程也可以访问 https://www.itying.com/category-93-b0.html


针对安卓手机 plus.nativeObj.View() 的 touchend 事件概率性不触发问题,建议以下解决方案:
添加 touchcancel 事件作为兜底: this.view.addEventListener(“touchcancel”, (event) => {
this.touchEnd(event, moveEndCallback);
}); 检查事件冒泡和捕获: 确保没有其他元素拦截了 touch 事件,可以在父容器也添加相应事件监听。
边界处理优化: 当手指移出 View 范围时,可以尝试在 touchmove 中主动触发 touchend 逻辑。
这个问题可能是安卓系统对原生 View 的事件处理机制差异导致的,建议在官方社区反馈此问题以获得更官方的解决方案。
内容为 AI 生成,仅供参考

import {measureTextWidth} from ‘@/utils/TextUtils’

type NotifyStyle = {height: number; width: number; top: number; left: number}
type TouchEventData = {
clientX: number;
clientY: number;
pageX: number;
pageY: number;
screenX: number;
screenY: number;
target: EventTarget;
currentImageIndex?: number;
}
type NotifyContent = {
title?: string
content: string
image?: string
duration?: number
}
const defaultImage = ‘_www/static/notice/MessageOutlined.png’
const defaultTitle = ‘通知’

class MessageNotify {
// 通知载体
private view: PlusNativeObjView

// 通知容器尺寸  
private noticeContainer: NotifyStyle  

// 通知图片尺寸  
private noticeImage: NotifyStyle  

// 通知标题尺寸  
private noticeTitle: NotifyStyle & {size: number}  

// 通知内容尺寸  
private noticeContent: NotifyStyle & {size: number}  

// 圆角  
private radius: string  

// 通知状态为开启  
private noticeIsShow: boolean  

// 通知点击事件  
private clickEvent?: () => void  

// 通知移动事件  
private moveEndEvent?: (direction: 'right' | 'left' | 'bottom' | 'top') => void  

// 自动关闭毫秒数  
private duration:number  

// 关闭延迟  
private closeTimeout: any  

// 通知栏滑动状态  
private draggingMeta: {  
    // 是否正在滑动  
    noticeIsdragging: boolean  
    // 开始的y值  
    startY: number,  
    // 开始的x值  
    startX: number,  
    // 透明度  
    opacity?: number,  
    // 滑动方向  
    direction?: 'x' | 'y',  
    // 向下滑动的最大top值  
    maxTop: number,  
    // 当前的top值  
    currentTop: number  
}  
// 操作系统名称  
private osName: string  

// 系统主题  
private theme: string  
// 颜色  
private color: {  
    // 卡片背景颜色  
    backageColor: '#e5e5e5' | '#2b2b2b',  
    // 标题颜色  
    titleColor: '#000' | '#fff',  
    // 内容颜色  
    contentColor: 'rgba(0, 0, 0, 0.45)' | 'rgba(255, 255, 255, 0.45)',  
    // 头像遮罩颜色  
    maskColor: 'rgba(0,0,0,0)' | 'rgba(0,0,0,0.2)'  
}  

// 通知内容  
private notifyContent?: NotifyContent  

constructor() {  
    // 系统信息  
    const sysInfo = uni.getSystemInfoSync()  
    // 初始化容器尺寸  
    const windowInfo = uni.getWindowInfo()  
    // 操作系统名称  
    this.osName = sysInfo.osName  
    // 当前主题  
    this.theme = sysInfo.theme || 'light'  
    // 容器最大宽度,480 以下的设备都以边距16进行计算  
    const maxWidth = 480  
    // 边距  
    const margin = 8  
    // 高度  
    const height = 72  
    // 容器全部宽度  
    const width = windowInfo.screenWidth > maxWidth ? maxWidth : windowInfo.screenWidth  
    // 左位置  
    const left = width === maxWidth ? (windowInfo.screenWidth - width) / 2 : margin  

    // 圆角  
    this.radius = "16px"  
    // 通知是否在显示中  
    this.noticeIsShow = false  
    // 自动关闭毫秒数  
    this.duration = 3000  

    // 容器尺寸  
    this.noticeContainer = {  
        width: width - left * 2,  
        height: height,  
        top: windowInfo.statusBarHeight,  
        left: left  
    }  

    // 图片尺寸  
    this.noticeImage = {  
        top: margin,  
        left: margin,  
        width: height - margin * 2,  
        height: height - margin * 2  
    }  

    // 标题尺寸  
    this.noticeTitle = {  
        top: margin * 1.5,  
        left: this.noticeImage.width + margin * 2,  
        width: width - (this.noticeImage.width + margin * 3 + margin),  
        height: height / 2,  
        size: 17  
    }  

    // 内容尺寸  
    this.noticeContent = {  
        top: height / 2 + margin / 2,  
        left: this.noticeImage.width + margin * 2,  
        width: width - (this.noticeImage.width + margin * 3 + margin),  
        height: height / 2,  
        size: 16  
    }  

    // 创建原生View对象  
    this.view = new (plus as any).nativeObj.View('messageNotify', {  
        top: this.noticeContainer.top + 'px',  
        left: this.noticeContainer.left + 'px',  
        width: this.noticeContainer.width + 'px',  
        height: this.noticeContainer.height + 'px'  
    });  

    // 滑动元数据  
    this.draggingMeta = {noticeIsdragging: false, startX: 0, startY: 0, maxTop: windowInfo.screenHeight * (1 / 3), currentTop: this.noticeContainer.top}  

    // 添加点击、滑动事件  
    this.addEventListener(() => {  
        if (this.clickEvent) {  
            // 滑动过程中无法触发点击事件  
            if (this.draggingMeta.noticeIsdragging) {  
                return  
            }  
            // 触发业务点击  
            this.clickEvent()  
        }  
        // 关闭通知  
        this.hide()  
    }, (direction) => {  
        // 触发业务滑动  
        if (this.moveEndEvent) {  
            this.moveEndEvent(direction)  
        }  
    })  

    // 监听主题变化  
    this.watchTheme()  

    // 根据当前主题赋值颜色  
    if (this.theme === 'light') {  
        this.color = {  
            backageColor: '#e5e5e5',  
            titleColor: '#000',  
            contentColor: 'rgba(0, 0, 0, 0.45)',  
            maskColor: 'rgba(0,0,0,0)',  
        }  
    } else {  
        this.color = {  
            backageColor: '#2b2b2b',  
            titleColor: '#fff',  
            contentColor: 'rgba(255, 255, 255, 0.45)',  
            maskColor: 'rgba(0,0,0,0.2)',  
        }  
    }  
}  

/**  
 * 显示弹窗  
 */  
public show = (notifyContent: NotifyContent, clickCallback?: () => void, moveEndCallback?: (direction: 'right' | 'left' | 'bottom' | 'top') => void) => {  
    const {title, content, image, duration} = notifyContent  
    if (!content) {  
        throw new Error("通知内容不存在")  
    }  
    // 赋值消息内容  
    this.notifyContent = notifyContent  
    // 赋值自动消失时间  
    if (duration) {  
        this.duration = duration  
    }  
    // 赋值事件  
    this.clickEvent = clickCallback  
    this.moveEndEvent = moveEndCallback  

    // 在拖动过程中有新消息,直接重绘  
    if (this.draggingMeta.noticeIsdragging) {  
        this.view.reset()  
        this.drawNotice(title || defaultTitle, content, image || defaultImage)  
        return  
    }  

    const step = this.noticeContainer.top / 10  

    // 先关闭已存在的消息再打开  
    this.hide().then(() => {  
        // 设置动画开始时top值和透明度  
        this.view.setStyle({top: '0px', opacity: 0, left: this.noticeContainer.left + 'px'})  
        // 绘制通知  
        this.drawNotice(title || defaultTitle, content, image || defaultImage)  
        // 显示通知(窗口在屏幕外,并且透明度为0)  
        this.view.show()  
        this.noticeIsShow = true  
        // 执行动画,由上向下滑落,减小透明度  
        let top = 0  
        let opacity = 0  
        const interval = setInterval(() => {  
            // top值判断动画是否结束  
            if (top >= this.noticeContainer.top) {  
                this.view.setStyle({top: this.noticeContainer.top + 'px', opacity: 1, left: this.noticeContainer.left + 'px'})  
                clearInterval(interval)  
                this.autoClose()  
                return  
            }  
            // 每帧步进  
            top = top + step  
            opacity = opacity + 0.2  
            // 刷新样式  
            this.view.setStyle({  
                top: top + 'px',  
                opacity: opacity  
            })  
        }, 8)  
    })  
}  

/**  
 * 关闭弹窗  
 * 使用定时器渐变消失  
 */  
public hide = () => {  
    return new Promise((resolve, _reject) => {  
        // 取消自动关闭  
        this.cancelAutoClose()  
        // 通知为关闭状态直接返回  
        if (!this.noticeIsShow) {  
            resolve({})  
            return  
        }  
        let opacity = 1  
        let interval = setInterval(() => {  
            if (opacity <= 0) {  
                clearInterval(interval)  
                // 销毁并关闭组件  
                this.view.reset()  
                this.view.hide()  
                this.noticeIsShow = false  
                this.draggingMeta.noticeIsdragging = false  
                resolve({})  
                return  
            }  
            opacity = opacity - 0.2  
            this.view.setStyle({  
                opacity: opacity  
            })  
        }, 16)  
    })  
}  

// 绘制通知  
private drawNotice = (title: string, content: string, image: string) => {  
    const {backageColor, titleColor, contentColor, maskColor} = this.color  
    // 绘制通知最外层content  
    this.view.drawRect({color: backageColor, radius: this.radius})  
    // 绘制标题  
    this.view.drawText(this.textCut(title, this.noticeTitle.size, this.noticeTitle.width), {top: this.noticeTitle.top + 'px', left: this.noticeTitle.left + 'px', width: this.noticeTitle.width + 'px', height: this.noticeTitle.height + 'px'}, {align: 'left', verticalAlign: 'top', size: this.noticeTitle.size + 'px', color: titleColor})  
    // 绘制内容  
    this.view.drawText(this.textCut(content, this.noticeContent.size, this.noticeContent.width), {top: this.noticeContent.top + 'px', left: this.noticeContent.left + 'px', width: this.noticeContent.width + 'px', height: this.noticeContent.height + 'px'}, {align: 'left', verticalAlign: 'top', color: contentColor, size: this.noticeContent.size + 'px'})  
    // 绘制左侧图片  
    this.view.drawBitmap(image, {}, {top: this.noticeImage.top + 'px', left: this.noticeImage.left + 'px', width: this.noticeImage.width + 'px', height: this.noticeImage.height + 'px'})  
    // 绘制图片遮罩,呈现圆角(安卓和ios渲染方式不同,根据系统类型进行调用)  
    if (this.osName === 'android') {  
        this.view.drawRect({color: maskColor, borderWidth: this.noticeImage.left * 2 + 'px', radius: this.radius, borderColor: backageColor}, {height: this.noticeContainer.height - this.noticeImage.left * 2 + 'px',width: this.noticeContainer.height - this.noticeImage.left * 2 + 'px', top: this.noticeImage.left + 'px', left: this.noticeImage.left + 'px'}, 'mask')  
    } else {  
        this.view.drawRect({color: maskColor, borderWidth: this.noticeImage.left + 'px' ,radius: this.radius, borderColor: backageColor}, {height: this.noticeContainer.height + 'px', width: this.noticeImage.width + this.noticeImage.left * 2 + 'px'}, 'mask')  
    }  
}  

// 添加事件  
private addEventListener = (clickCallback: () => void, moveEndCallback: (direction: 'right' | 'left' | 'bottom' | 'top') => void) => {  
    this.view.addEventListener("click", clickCallback)  
    this.view.addEventListener("touchstart", this.touchStart)  
    this.view.addEventListener("touchmove", this.touchMove)  
    this.view.addEventListener("touchend", (event: TouchEventData) => this.touchEnd(event, moveEndCallback))  
}  

// 开始滑动  
private touchStart = (event: TouchEventData) => {  
    this.draggingMeta.startX = event.screenX  
    this.draggingMeta.startY = event.screenY  
    // 取消自动关闭  
    this.cancelAutoClose()  
}  

// 滑动过程中  
private touchMove = (event: TouchEventData) => {  
    if (!this.draggingMeta.direction) {  
        // 判断滑动方向  
        const {screenX, screenY} = event  
        const x = Math.abs(this.draggingMeta.startX - screenX)  
        const y = Math.abs(this.draggingMeta.startY - screenY)  
        this.draggingMeta.direction = x > y ? 'x' : 'y'  
        // 修改滑动状态  
        this.draggingMeta.noticeIsdragging = true  
    }  

    // 上下滑动  
    if (this.draggingMeta.direction === 'y' && this.draggingMeta.startY !== 0) {  
        let targetTop = this.noticeContainer.top + event.screenY - this.draggingMeta.startY  
        // 滑动距离(为正数表示向下滑动)  
        const specificDirection = event.screenY - this.draggingMeta.startY  

        // 下滑,有阻尼  
        if (specificDirection > 0) {  
            this.draggingMeta.opacity = 1  
            // 计算算上阻尼的targetTop  
            // 阻尼系数,越高越容易滑动  
            const DAMPING = 300;  
            const damped = (specificDirection * DAMPING) / (specificDirection + DAMPING)  
            targetTop = this.noticeContainer.top + damped;  
        } else {  
            this.draggingMeta.opacity = 1 - Math.abs(Math.trunc(specificDirection)) * 0.005  
        }  
        const maxTop = this.draggingMeta.maxTop  
        this.draggingMeta.currentTop =  targetTop >= maxTop ? maxTop : targetTop   

        this.view.setStyle({  
            top: this.draggingMeta.currentTop + 'px',  
            opacity: this.draggingMeta.opacity  
        })  
    }  

    // 左右滑动  
    if (this.draggingMeta.direction === 'x' && this.draggingMeta.startX !== 0) {  
        const targetLeft = this.noticeContainer.left + event.screenX - this.draggingMeta.startX  
        const specificDirection = Math.abs(event.screenX - this.draggingMeta.startX)  
        const opacity = 1 - Math.trunc(specificDirection) * 0.005  
        this.draggingMeta.opacity = opacity  
        this.view.setStyle({  
            left: targetLeft + 'px',  
            opacity: opacity  
        })  
    }  
}  

// 滑动结束  
private touchEnd = (event: TouchEventData, moveEndCallback: (direction: 'right' | 'left' | 'bottom' | 'top') => void) => {  
    console.log("执行了滑动结束");  
    // 滑动方向  
    let direction: 'right' | 'left' | 'bottom' | 'top'  
    // 关闭阈值  
    let threshold: number = 0  
    if (this.draggingMeta.direction === 'x') {  
        direction = event.screenX > this.draggingMeta.startX ? 'right' : 'left'  
        threshold = 0.4  
    } else {  
        direction = event.screenY > this.draggingMeta.startY? 'bottom' : 'top'  
        direction === 'top' ? threshold = 0.8 : threshold = 0.4  
    }  
    // 滑动满足阈值后即销毁关闭  
    if (this.draggingMeta.opacity && this.draggingMeta.opacity < threshold) {  
        this.view.reset()  
        this.view.hide()  
        if (moveEndCallback) {  
            moveEndCallback(direction)  
        }  
    } else {  
        // 否则复原  
        this.view.setStyle({top: this.noticeContainer.top + 'px', opacity: 1, left: this.noticeContainer.left + 'px'})  
    }  
    // 向下滑动超过maxTop比例则触发回调  
    if (direction === 'bottom' && this.draggingMeta.currentTop > this.draggingMeta.maxTop * (3 / 5)) {  
        if (moveEndCallback) {  
            moveEndCallback(direction)  
        }  
    }  
    // 重置拖动状态  
    setTimeout(() => {  
        this.draggingMeta.noticeIsdragging = false  
        this.draggingMeta.startX = 0  
        this.draggingMeta.startY = 0  
        this.draggingMeta.direction = undefined  
    }, 20)  
    // 重新开始自动关闭  
    this.autoClose()  
}  

// 截取文本  
private textCut = ( text: string, fontSize: number, contentWidth: number ) => {  
  if (!text) return ''  

  const ellipsis = '...'  
  const ellipsisWidth = measureTextWidth(ellipsis, fontSize)  
  contentWidth = contentWidth  
  // 宽度过小  
  if (ellipsisWidth > contentWidth) {  
    return ''  
  }  

  // 原文无需截取  
  if (measureTextWidth(text, fontSize) <= contentWidth) {  
    return text  
  }  

  let left = 0  
  let right = text.length  
  let result = ''  

  while (left <= right) {  
    const mid = Math.floor((left + right) / 2)  
    const slice = text.slice(0, mid)  
    const width = measureTextWidth(slice, fontSize) + ellipsisWidth  

    if (width <= contentWidth) {  
      result = slice  
      left = mid + 1  
    } else {  
      right = mid - 1  
    }  
  }  

  return result + ellipsis  
}  

// 自动关闭  
private autoClose = () => {  
    this.closeTimeout = setTimeout(() => {  
        this.hide()  
        clearTimeout(this.closeTimeout)  
    }, this.duration)  
}  

// 取消自动关闭  
private cancelAutoClose = () => {  
    if (this.closeTimeout) {  
        clearTimeout(this.closeTimeout)  
    }  
}  

// 监听主题变化  
private watchTheme = () => {  
    uni.onThemeChange((resp) => {  
        this.theme = resp.theme  
        if (this.theme === 'light') {  
            this.color = {  
                backageColor: '#e5e5e5',  
                titleColor: '#000',  
                contentColor: 'rgba(0, 0, 0, 0.45)',  
                maskColor: 'rgba(0,0,0,0)',  
            }  
        } else {  
            this.color = {  
                backageColor: '#2b2b2b',  
                titleColor: '#fff',  
                contentColor: 'rgba(255, 255, 255, 0.45)',  
                maskColor: 'rgba(0,0,0,0.2)',  
            }  
        }  

        // 通知正在开启时变换主题,重新绘制  
        if (this.noticeIsShow && this.notifyContent) {  
            const {title, content, image} = this.notifyContent  
            this.view.reset()  
            this.drawNotice(title || defaultTitle, content, image || defaultImage)  
        }  
    });  
}  

}

export default new MessageNotify()

没有提供touchcancel的监听,我也希望有touchcancel。希望回调返回的参数与touchend一致

这是一个已知的Android原生View事件处理机制问题。当手指滑动超出原生View边界时,Android系统会中断触摸事件序列,导致touchend无法正常触发。

解决方案:

  1. 使用touchcancel事件兜底: 在添加事件监听时,同时监听touchcancel事件,作为touchend的补充:

    this.view.addEventListener("touchcancel", (event: TouchEventData) => {
        // 处理与touchend相同的逻辑
        this.touchEnd(event, moveEndCallback);
    });
    
  2. 优化触摸事件处理逻辑: 在touchmove事件中记录触摸状态,当检测到触摸点移出View边界时,主动触发结束逻辑:

    private touchMove = (event: TouchEventData) => {
        // 记录触摸点位置
        this.lastTouchPoint = event;
        
        // 检测是否移出边界
        const rect = this.view.getRect();
        if (event.clientX < rect.left || event.clientX > rect.right ||
            event.clientY < rect.top || event.clientY > rect.bottom) {
            // 主动触发结束逻辑
            this.handleTouchEnd();
        }
    };
    
  3. 使用全局触摸监听: 在页面级别添加触摸监听,捕获全局的触摸结束事件:

    document.addEventListener('touchend', this.globalTouchEndHandler);
回到顶部