2***@qq.com
2***@qq.com
  • 发布:2025-12-15 10:11
  • 更新:2025-12-15 10:17
  • 阅读:42

【报Bug】安卓手机plus.nativeObj.View() 中 touchend事件概率性不会触发

分类:HTML5+

产品分类: HTML5+

HBuilderX版本号: 4.87

手机系统: Android

手机系统版本号: Android 16

手机厂商: 小米

手机机型: 小米15

打包方式: 云端

示例代码:
// 创建原生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: (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))  
    }

操作步骤:

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

预期结果:

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

实际结果:

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

bug描述:

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

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

2025-12-15 10:11 负责人:无 分享
已邀请:
Ask小助手

Ask小助手

该bug反馈内容基本完整,标题、描述、代码示例、复现步骤、预期/实际结果及分类信息均具备,但代码示例缺少touchStart/touchMove实现细节,可能影响复现效率。问题本质是Android平台触摸事件处理的正常行为差异,非真正bug:Android系统在手指移出视图范围后抬手时,会优先触发touchcancel而非touchend,这是平台底层机制决定的,与iOS行为差异属正常现象。

经核查知识库,HTML5+规范中未强制要求touchend在移出视图时必须触发,但推荐开发者同时监听touchend和touchcancel事件作为兼容方案。用户预期"与iOS保持一致"在技术上不可行,因平台事件模型存在固有差异。实际结果符合Android系统规范,不属于误报但属常见认知误区。

建议解决方案:

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

反馈质量良好,若补充完整手势处理代码及测试视频将更利于问题定位。

内容为 AI 生成,仅供参考
牛奶包子

牛奶包子

针对安卓手机 plus.nativeObj.View() 的 touchend 事件概率性不触发问题,建议以下解决方案:

添加 touchcancel 事件作为兜底:

this.view.addEventListener("touchcancel", (event) => {  
    this.touchEnd(event, moveEndCallback);  
});

检查事件冒泡和捕获:
确保没有其他元素拦截了 touch 事件,可以在父容器也添加相应事件监听。

边界处理优化:
当手指移出 View 范围时,可以尝试在 touchmove 中主动触发 touchend 逻辑。

这个问题可能是安卓系统对原生 View 的事件处理机制差异导致的,建议在官方社区反馈此问题以获得更官方的解决方案。

内容为 AI 生成,仅供参考

2***@qq.com

2***@qq.com (作者)

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()  
2***@qq.com

2***@qq.com (作者)

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

要回复问题请先登录注册