深圳幻海软件技术有限公司 欢迎您!

HarmonyOS自定义控件之速度检测VelocityDetector

2023-02-27

想了解更多内容,请访问:51CTO和华为官方合作共建的鸿蒙技术社区https://harmonyos.51cto.com一般在涉及到滚动的场景时,我们会用到速度检测。比如列表滑动时,我们需要拿到手指抬起时的瞬时速度,来做惯性滚动。又比如在滚动翻页时,我们要根据手指速度来判断是否翻到下一页还是继续保持

想了解更多内容,请访问:

51CTO和华为官方合作共建的鸿蒙技术社区

https://harmonyos.51cto.com

一般在涉及到滚动的场景时,我们会用到速度检测。比如列表滑动时,我们需要拿到手指抬起时的瞬时速度,来做惯性滚动。又比如在滚动翻页时,我们要根据手指速度来判断是否翻到下一页还是继续保持当页。

接下来我们就来看看HarmonyOS中的VelocityDetector如何使用。

使用方法

VelocityDetector使用起来还是比较简单的,主要是分为以下几步:

  • 获取VelocityDetector实例
  • 为VelocityDetector添加TouchEvent
  • 计算速度
  • 获取计算后的速度
  • 清除已添加的event

获取实例

通过obtainInstance函数获取实例:

VelocityDetector detector = VelocityDetector.obtainInstance(); 
  • 1.

添加TouchEvent

在控件的TouchEventListener内调用addEvent函数:

component.setTouchEventListener(new TouchEventListener() { 
    @Override 
    public boolean onTouchEvent(Component component, TouchEvent ev) { 
        detector.addEvent(ev); 
        return true
    } 
}); 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

计算速度

一般情况下,我们需要在手指抬起时计算速度,因为我们需要的是手指抬起后的速度值。因此我们可以在TouchEvent.PRIMARY_POINT_UP时调用calculateCurrentVelocity函数来计算速度:

static final int MAX_VELOCITY = 10000; 
 
@Override 
public boolean onTouchEvent(Component component, TouchEvent ev) { 
    detector.addEvent(ev); 
    if (ev.getAction() == TouchEvent.PRIMARY_POINT_UP) { 
        detector.calculateCurrentVelocity(1000, MAX_VELOCITY, MAX_VELOCITY); 
    } 
    return true

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

calculateCurrentVelocity函数有两个重载:

void calculateCurrentVelocity(int units); 
void calculateCurrentVelocity(int units, float maxVxVelocity, float maxVyVelocity) 
  • 1.
  • 2.

其中:

  • units为单位,1代表像素/毫秒,1000代表像素/秒,以此类推。一般情况下我们都传1000,获取的速度代表手指每秒移动多少像素
  • maxVxVelocity为横向最大速度为多少,比如惯性滚动时,如果我们不希望滚动过快,可以设置一个最大速度
  • maxVyVelocity为纵向最大速度为多少,比如惯性滚动时,如果我们不希望滚动过快,可以设置一个最大速度

获取速度

在计算速度之后就能直接获取速度值了:

float velocityY = detector.getVerticalVelocity(); 
float velocityX = detector.getHorizontalVelocity(); 
 
// 或者获取速度数组,下标0为横向速度,下标1为纵向速度 
float[] velocity = detector.getVelocity(); 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

获取到的速度可能是正值也可能是负值,正负值代表了速度的方向,这个大家可以通过日志自行实验一下。

清除

最后,我们需要清除前面添加的TouchEvent,为新一轮的事件做准备,避免旧的TouchEvent影响了后续的速度计算。这里我们在获取到速度后或者CANCEL事件中,就可以调用clear函数:

if (ev.getAction() == TouchEvent.PRIMARY_POINT_UP) { 
    ... 
    float[] velocity = detector.getVelocity(); 
    ... 
    detector.clear(); 

 
if (ev.getAction() == TouchEvent.CANCEL) { 
     detector.clear(); 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

总结

VelocityDetector目前只能获取一个手指的速度,在多点触控的情况下,暂时没法获取其他手指的速度。

到此我们就获取到了手指抬起时的速度了,至于怎么利用这个速度,后续会在惯性滚动相关的文章中讲述。接下来我们再来分析一下VelocityDetector存在什么问题。

问题

首先我们来了解一下VelocityDetector的基本原理:

我们通过addEvent将TouchEvent传递给VelocityDetector,然后通过calculateCurrentVelocity来计算速度,在这个过程中,VelocityDetector基本上就是通过TouchEvent拿到手指的坐标,然后通过移动距离以及时间来计算速度。当然内部算法远比说的复杂,但是我们只需要记住一个关键变量即可:移动距离。

TouchEvent有两个函数可以拿到手指坐标来计算距离:getPointerPosition与getPointerScreenPosition。VelocityDetector究竟用的哪一个呢?我们可以通过如下代码来实验:

@Override 
    public boolean onTouchEvent(Component component, TouchEvent ev) { 
        detector.addEvent(cloneEvent(ev)); 
         
        return true
    } 
 
    private TouchEvent cloneEvent(TouchEvent event) { 
        return new TouchEvent() { 
 
            @Override 
            public int getIndex() { 
                System.out.println(TAG + "getIndex"); 
                return event.getIndex(); 
            } 
 
            @Override 
            public MmiPoint getPointerPosition(int i) { 
                System.out.println(TAG + "getPointerPosition"); 
                return event.getPointerPosition(i); 
            } 
 
            @Override 
            public MmiPoint getPointerScreenPosition(int i) { 
                System.out.println(TAG + "getPointerScreenPosition"); 
                return event.getPointerScreenPosition(i); 
            } 
 
            ...... 
        }; 
    } 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.

在手指移动过程中,日志如下:

08-04 17:14:09.296 24871-24871/com.ryan.ohos.parallaxlayout I System.out:  ParallaxLayout TouchEvent: getIndex 
08-04 17:14:09.296 24871-24871/com.ryan.ohos.parallaxlayout I System.out:  ParallaxLayout TouchEvent: getPointerPosition 
08-04 17:14:09.297 24871-24871/com.ryan.ohos.parallaxlayout I System.out:  ParallaxLayout TouchEvent: getIndex 
08-04 17:14:09.297 24871-24871/com.ryan.ohos.parallaxlayout I System.out:  ParallaxLayout TouchEvent: getPointerPosition 
08-04 17:14:09.469 24871-24871/com.ryan.ohos.parallaxlayout I System.out:  ParallaxLayout TouchEvent: getIndex 
.... 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

答案很明显,VelocityDetector使用的是getPointerPosition。getPointerPosition获取的坐标是相对于父控件的,而不是屏幕的左上角,那么根据getPointerPosition的描述我们有理由猜测:

当被监听的控件,在手指移动过程中,不断的改变自己的位置,那么通过getPointerPosition获取的手指坐标会加上控件的位移量,导致滑动距离计算偏离预期。

下面我们来实验一下。在父布局中,子控件监听触摸事件,通过getPointerPosition获取手指坐标并计算MOVE与DOWN中坐标的差,并使用setComponentPosition与坐标差改变子控件的位置。

然后我们打印getPointerPosition获取的y坐标,getPointerScreenPosition获取的y坐标,以及移动距离,代码如下:

Component child = getComponentAt(1); 
child.setTouchEventListener(this); 
   int top = child.getTop(); 
 
@Override 
   public boolean onTouchEvent(Component component, TouchEvent ev) { 
       float y = getY(ev); 
       float screenY = getScreenY(ev); 
       switch (ev.getAction()) { 
           case TouchEvent.PRIMARY_POINT_DOWN: 
               downY = y; 
               downScreenY = screenY; 
               break; 
 
           case TouchEvent.POINT_MOVE: 
               float deltaY = y - downY; 
               float deltaScreenY = screenY - downScreenY; 
               System.out.println(TAG  + "y: " + y + " screenY: " + screenY + ", deltaY: " + deltaY + " deltaScreenY" + deltaScreenY); 
               moveChildren((int) deltaY); 
               break; 
       } 
 
       return true
   } 
 
private void moveChildren(int deltaY) { 
       child.setComponentPosition(0, top + deltaY, child.getWidth(), top + deltaY + child.getHeight()); 
   } 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.

日志如下:

y: 1206.0348, screenY: 1905.0348, deltaY: -13.782349, deltaScreenY: -13.782349  
y: 1095.856, screenY: 1781.856, deltaY: -123.96118, deltaScreenY: -136.96118  
y: 1204.7794, screenY: 1780.7794, deltaY: -15.03772, deltaScreenY: -138.03772  
y: 1041.5786, screenY: 1725.5786, deltaY: -178.23853, deltaScreenY: -193.23853  
y: 1094.1056, screenY: 1615.1056, deltaY: -125.71155, deltaScreenY: -303.71155  
y: 972.12244, screenY: 1546.1224, deltaY: -247.6947, deltaScreenY: -372.6947  
y: 1066.4863, screenY: 1518.4863, deltaY: -153.33081, deltaScreenY: -400.3308  
y: 917.2855, screenY: 1463.2855, deltaY: -302.53162, deltaScreenY: -455.53162  
y: 1024.8671, screenY: 1421.8671, deltaY: -194.95007, deltaScreenY: -496.95007  
y: 875.4486, screenY: 1380.4486, deltaY: -344.36853, deltaScreenY: -538.3685  
y: 941.0178, screenY: 1296.0178, deltaY: -278.79932, deltaScreenY: -622.7993  
y: 821.4109, screenY: 1242.4109, deltaY: -398.40625, deltaScreenY: -676.40625  
y: 883.01855, screenY: 1184.0186, deltaY: -336.79858, deltaScreenY: -734.7986  
y: 817.2832, screenY: 1180.2832, deltaY: -402.53394, deltaScreenY: -738.53394  
y: 834.93787, screenY: 1131.9379, deltaY: -384.87927, deltaScreenY: -786.8793  
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

可以发现通过getPointerPosition计算出来deltaY是忽大忽小而不是线性增加的,并且与getPointerScreenPosition计算的deltaScreenY对比可以发现,deltaY等于deltaScreenY减去上一次的deltaY。也就证明了:通过getPointerPosition获取的手指坐标会加上该控件的位移量。

那么这对VelocityDetector有什么影响呢?VelocityDetector计算速度有一个重要的因素就是距离,在这种情况下距离忽大忽小,就会导致速度计算出来的值会小于正常速度,甚至于正负值都不太一样。

总结一下:当一个控件在该控件的触摸事件内,改变了自己相对于父控件的位置,那么通过VelocityDetector获取的速度就会出现误差。能影响控件位置的函数有setTop(在实验中setTop未能改变控件的位置,还不确定是为什么)、setContentPosition、setComponentPosition,甚至还包括setTranslationY、setTranslationX。并且如果在该控件的触摸事件内,父控件改变了位置,也会产生此问题。

在这种情况下,触摸事件内计算距离的问题好解决,不使用getPointerPosition直接使用getPointerScreenPosition即可。但是VelocityDetector的问题如何解决呢?两个办法:代理法与偏移法。

代理法

通过一个TouchEventProxy,内部维护一个TouchEvent,并将其getPointerPosition实现转发至TouchEvent的getPointerScreenPosition中。

public class TouchEventProxy extends TouchEvent { 
 
    private TouchEvent event; 
 
    public void setEvent(TouchEvent event) { 
        this.event = event; 
    } 
 
    @Override 
    public int getAction() { 
        return event.getAction(); 
    } 
 
    @Override 
    public int getIndex() { 
        return event.getIndex(); 
    } 
 
    @Override 
    public long getStartTime() { 
        return event.getStartTime(); 
    } 
 
    @Override 
    public int getPhase() { 
        return event.getPhase(); 
    } 
 
    @Override 
    public MmiPoint getPointerPosition(int i) { 
        // 转发至getPointerScreenPosition 
        return event.getPointerScreenPosition(i); 
    } 
 
    @Override 
    public void setScreenOffset(float v, float v1) { 
        event.setScreenOffset(v, v1); 
    } 
 
    @Override 
    public MmiPoint getPointerScreenPosition(int i) { 
        return event.getPointerScreenPosition(i); 
    } 
 
    @Override 
    public int getPointerCount() { 
        return event.getPointerCount(); 
    } 
 
    @Override 
    public int getPointerId(int i) { 
        return event.getPointerId(i); 
    } 
 
    @Override 
    public float getForce(int i) { 
        return event.getForce(i); 
    } 
 
    @Override 
    public float getRadius(int i) { 
        return event.getRadius(i); 
    } 
 
    @Override 
    public int getSourceDevice() { 
        return event.getSourceDevice(); 
    } 
 
    @Override 
    public String getDeviceId() { 
        return event.getDeviceId(); 
    } 
 
    @Override 
    public int getInputDeviceId() { 
        return event.getInputDeviceId(); 
    } 
 
    @Override 
    public long getOccurredTime() { 
        return event.getOccurredTime(); 
    } 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.

 使用起来也很简单:

TouchEventProxy proxy = new TouchEventProxy(); 
 
    @Override 
    public boolean onTouchEvent(Component component, TouchEvent ev) { 
        proxy.setEvent(ev); 
        detector.addEvent(proxy); 
        ...... 
        return true
    } 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

位移法

通过反射TouchEvent发现,其内部含有能设置偏移量的函数,该函数会影响getPointerPosition的值。那么我们就可以在触摸事件内,对比getPointerPosition与getPointerScreenPosition的差,并通过函数设置偏移,强制使坐标同步。这里只提供位移法的可行并验证过的思路,代码大家可以自行尝试。

对比

既然有方法可以修复速度的问题,那么我们就可以对比修复前与修复后的速度,到底有多少差距。我们定义两个VelocityDetector实例,一个add代理,一个add原始的event,然后同时获取速度来看看:

  VelocityDetector detector1 = VelocityDetector.obtainInstance(); 
   VelocityDetector detector2 = VelocityDetector.obtainInstance(); 
TouchEventProxy proxy = new TouchEventProxy(); 
 
@Override 
   public boolean onTouchEvent(Component component, TouchEvent ev) { 
       proxy.setEvent(ev); 
 
       detector1.addEvent(ev); 
       detector2.addEvent(proxy); 
        
       float y = getY(ev); 
       switch (ev.getAction()) { 
           case TouchEvent.PRIMARY_POINT_DOWN: 
               downY = y; 
               break; 
 
           case TouchEvent.POINT_MOVE: 
               float deltaY = y - downY; 
               moveChildren((int) deltaY); 
               break; 
 
           case TouchEvent.PRIMARY_POINT_UP: 
               detector1.calculateCurrentVelocity(1000); 
               detector2.calculateCurrentVelocity(1000); 
               System.out.println(TAG + "detector1: " + detector1.getVerticalVelocity() + ", detector2: " + detector2.getVerticalVelocity()); 
 
               detector1.clear(); 
               detector2.clear(); 
               break; 
       } 
 
       return true
   } 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.

快速上滑:

08-05 09:36:29.004 1846-1846/? I System.out:  ParallaxLayout TouchEvent: detector1: -5332.0, detector2: -9285. 
  • 1.

慢一点上滑:

08-05 09:35:39.065 1846-1846/? I System.out: ParallaxLayout TouchEvent: detector1: -1003.0, detector2: -3560.0 
  • 1.

先慢速最后快速上滑:

08-05 09:37:04.066 1846-1846/? I System.out: ParallaxLayout TouchEvent: detector1: -4176.0, detector2: -4491.0 
  • 1.

快速下滑:

08-05 09:39:44.785 1846-1846/? I System.out: ParallaxLayout TouchEvent: detector1: 1955.0, detector2: 6660.0 
  • 1.

慢速下滑:

08-05 09:40:32.813 1846-1846/? I System.out: ParallaxLayout TouchEvent: detector1: 907.0, detector2: 3835.0 
  • 1.

先慢速最后快速下滑:

08-05 09:39:15.739 1846-1846/? I System.out: ParallaxLayout TouchEvent: detector1: -784.0, detector2: 1937.0 
  • 1.

总结

可以发现上滑过程采样越少(慢速突然变快的情况)两个速度越接近,但是在下滑过程中,如果速度比较慢甚至会得到一个方向相反的速度。

想了解更多内容,请访问:

51CTO和华为官方合作共建的鸿蒙技术社区

https://harmonyos.51cto.com