Skip to content

Commit 9bbcd2d

Browse files
committed
Fixed no OnChangeListener fires when value changed with single click.
Fixed set same values triggering listener. Resolves #716 Resolves #853 PiperOrigin-RevId: 288608132
1 parent 7b0a41d commit 9bbcd2d

2 files changed

Lines changed: 204 additions & 19 deletions

File tree

lib/java/com/google/android/material/slider/Slider.java

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,8 @@ private void processAttributes(Context context, AttributeSet attrs, int defStyle
427427
}
428428

429429
@NonNull
430-
private TooltipDrawable parseLabelDrawable(@NonNull Context context, @NonNull TypedArray a) {
430+
private static TooltipDrawable parseLabelDrawable(
431+
@NonNull Context context, @NonNull TypedArray a) {
431432
return TooltipDrawable.createFromAttributes(
432433
context,
433434
null,
@@ -540,9 +541,11 @@ public float getValue() {
540541
*/
541542
public void setValue(float value) {
542543
if (isValueValid(value)) {
543-
thumbPosition = (value - valueFrom) / (valueTo - valueFrom);
544-
dispatchOnChanged(false);
545-
invalidate();
544+
float newThumbPosition = (value - valueFrom) / (valueTo - valueFrom);
545+
if (snapThumbPosition(newThumbPosition)) {
546+
dispatchOnChanged(false);
547+
invalidate();
548+
}
546549
}
547550
}
548551

@@ -803,6 +806,18 @@ public void setLabelBehavior(@LabelBehavior int labelBehavior) {
803806
}
804807
}
805808

809+
/** Returns the side padding of the track. */
810+
@Dimension()
811+
public int getTrackSidePadding() {
812+
return trackSidePadding;
813+
}
814+
815+
/** Returns the width of the track in pixels. */
816+
@Dimension()
817+
public int getTrackWidth() {
818+
return trackWidth;
819+
}
820+
806821
/**
807822
* Returns the height of the track in pixels.
808823
*
@@ -1196,7 +1211,7 @@ private void drawActiveTrack(@NonNull Canvas canvas, int width, int top) {
11961211
}
11971212

11981213
private void drawTicks(@NonNull Canvas canvas) {
1199-
int pivotIndex = pivotIndex(visibleTicksCoordinates);
1214+
int pivotIndex = pivotIndex(visibleTicksCoordinates, thumbPosition);
12001215
canvas.drawPoints(visibleTicksCoordinates, 0, pivotIndex * 2, activeTicksPaint);
12011216
canvas.drawPoints(
12021217
visibleTicksCoordinates,
@@ -1263,14 +1278,14 @@ public boolean onTouchEvent(@NonNull MotionEvent event) {
12631278
getParent().requestDisallowInterceptTouchEvent(true);
12641279
requestFocus();
12651280
thumbIsPressed = true;
1266-
thumbPosition = position;
1267-
snapThumbPosition();
1281+
if (snapThumbPosition(position)) {
1282+
dispatchOnChanged(true);
1283+
}
12681284
updateHaloHotspot();
12691285
ensureLabel();
12701286
updateLabelPosition();
12711287
invalidate();
12721288
onStartTrackingTouch();
1273-
dispatchOnChanged(true);
12741289
break;
12751290
case MotionEvent.ACTION_MOVE:
12761291
if (!thumbIsPressed) {
@@ -1282,18 +1297,19 @@ public boolean onTouchEvent(@NonNull MotionEvent event) {
12821297
onStartTrackingTouch();
12831298
}
12841299
thumbIsPressed = true;
1285-
thumbPosition = position;
1286-
snapThumbPosition();
1300+
if (snapThumbPosition(position)) {
1301+
dispatchOnChanged(true);
1302+
}
12871303
updateHaloHotspot();
12881304
ensureLabel();
12891305
updateLabelPosition();
12901306
invalidate();
1291-
dispatchOnChanged(true);
12921307
break;
12931308
case MotionEvent.ACTION_UP:
12941309
thumbIsPressed = false;
1295-
thumbPosition = position;
1296-
snapThumbPosition();
1310+
if (snapThumbPosition(position)) {
1311+
dispatchOnChanged(true);
1312+
}
12971313
ViewUtils.getContentViewOverlay(this).remove(label);
12981314
onStopTrackingTouch();
12991315
invalidate();
@@ -1316,16 +1332,34 @@ private void ensureLabel() {
13161332
}
13171333
}
13181334

1319-
/** Calculates the index of the thumb for the given tick coordinates */
1320-
private int pivotIndex(float[] coordinates) {
1321-
return Math.round(thumbPosition * (coordinates.length / 2 - 1));
1335+
/**
1336+
* Calculates the index the closest tick coordinates that the thumb should snap to.
1337+
*
1338+
* @param coordinates Tick coordinates defined in {@code #setTicksCoordinates()}.
1339+
* @param position Actual thumb position.
1340+
* @return Index of the closest tick coordinate.
1341+
*/
1342+
private static int pivotIndex(float[] coordinates, float position) {
1343+
return Math.round(position * (coordinates.length / 2 - 1));
13221344
}
13231345

1324-
private void snapThumbPosition() {
1346+
/**
1347+
* Snaps the thumb position to the closest tick coordinates in discrete mode, and the input
1348+
* position in continuous mode.
1349+
*
1350+
* @param eventPosition Position of the user's event.
1351+
* @return true, if {@code #thumbPosition is updated}; false, otherwise.
1352+
*/
1353+
private boolean snapThumbPosition(float eventPosition) {
13251354
if (stepSize > 0.0f) {
1326-
int intervalsCovered = pivotIndex(ticksCoordinates);
1327-
thumbPosition = (float) intervalsCovered / (ticksCoordinates.length / 2 - 1);
1355+
int intervalsCovered = pivotIndex(ticksCoordinates, eventPosition);
1356+
eventPosition = (float) intervalsCovered / (ticksCoordinates.length / 2 - 1);
13281357
}
1358+
if (eventPosition == thumbPosition) {
1359+
return false;
1360+
}
1361+
thumbPosition = eventPosition;
1362+
return true;
13291363
}
13301364

13311365
private void updateLabelPosition() {
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* Copyright (C) 2020 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.android.material.slider;
18+
19+
import com.google.android.material.R;
20+
21+
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
22+
import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
23+
import static org.mockito.ArgumentMatchers.eq;
24+
import static org.mockito.Mockito.mock;
25+
import static org.mockito.Mockito.never;
26+
import static org.mockito.Mockito.times;
27+
import static org.mockito.Mockito.verify;
28+
29+
import android.app.ActionBar.LayoutParams;
30+
import android.os.SystemClock;
31+
import androidx.appcompat.app.AppCompatActivity;
32+
import android.view.MotionEvent;
33+
import android.widget.LinearLayout;
34+
import androidx.test.core.app.ApplicationProvider;
35+
import com.google.android.material.slider.Slider.OnChangeListener;
36+
import org.junit.Before;
37+
import org.junit.Test;
38+
import org.junit.runner.RunWith;
39+
import org.mockito.AdditionalMatchers;
40+
import org.robolectric.Robolectric;
41+
import org.robolectric.RobolectricTestRunner;
42+
43+
/** Tests for events of {@link com.google.android.material.slider.Slider} */
44+
@RunWith(RobolectricTestRunner.class)
45+
public class SliderEventTest {
46+
47+
private static final float FLOAT_ERROR = 1e-4f;
48+
49+
private static final float SLIDER_VALUE_FROM = 0f;
50+
private static final float SLIDER_VALUE_TO = 100f;
51+
private static final float SLIDER_VALUE_RANGE = SLIDER_VALUE_TO - SLIDER_VALUE_FROM;
52+
private static final float SLIDER_STEP_VALUE = 1f;
53+
54+
private Slider slider;
55+
private OnChangeListener mockOnChangeListener;
56+
57+
@Before
58+
public void createSlider() {
59+
ApplicationProvider.getApplicationContext().setTheme(R.style.Theme_MaterialComponents_Bridge);
60+
AppCompatActivity activity = Robolectric.buildActivity(AppCompatActivity.class).setup().get();
61+
62+
// Creates slider and adds the listener.
63+
slider = new Slider(activity);
64+
slider.setValueFrom(SLIDER_VALUE_FROM);
65+
slider.setValueTo(SLIDER_VALUE_TO);
66+
67+
mockOnChangeListener = mock(OnChangeListener.class);
68+
slider.addOnChangeListener(mockOnChangeListener);
69+
70+
// Makes sure getParent() won't return null.
71+
LinearLayout container = new LinearLayout(activity);
72+
container.setPadding(50, 50, 50, 50);
73+
container.setOrientation(LinearLayout.VERTICAL);
74+
// Prevents getContentView() dead loop.
75+
container.setId(android.R.id.content);
76+
// Adds slider to layout, and adds layout to activity.
77+
container.addView(slider, new LayoutParams(MATCH_PARENT, WRAP_CONTENT));
78+
activity.addContentView(container, new LayoutParams(MATCH_PARENT, WRAP_CONTENT));
79+
80+
// Changes the step size.
81+
slider.setStepSize(SLIDER_STEP_VALUE);
82+
}
83+
84+
@Test
85+
public void testSliderSingleClickCurrentPosition_ListenerShouldNotBeCalled() {
86+
float currentValue = slider.getValue();
87+
// Click pressed.
88+
touchSliderAtValue(slider, currentValue, MotionEvent.ACTION_DOWN);
89+
// Click released.
90+
touchSliderAtValue(slider, currentValue, MotionEvent.ACTION_UP);
91+
// Listener should not be called since value is not changed.
92+
verify(mockOnChangeListener, never())
93+
.onValueChange(eq(slider), eq(SLIDER_VALUE_FROM), eq(true));
94+
}
95+
96+
@Test
97+
public void testSliderSingleClickDifferentPosition_ListenerShouldBeCalled() {
98+
// Click pressed.
99+
touchSliderAtValue(slider, SLIDER_VALUE_FROM + SLIDER_VALUE_RANGE / 2, MotionEvent.ACTION_DOWN);
100+
// Click released.
101+
touchSliderAtValue(slider, SLIDER_VALUE_FROM + SLIDER_VALUE_RANGE / 2, MotionEvent.ACTION_UP);
102+
// Listener should be called once.
103+
verify(mockOnChangeListener, times(1))
104+
.onValueChange(
105+
eq(slider),
106+
AdditionalMatchers.eq(SLIDER_VALUE_FROM + SLIDER_VALUE_RANGE / 2, FLOAT_ERROR),
107+
eq(true));
108+
}
109+
110+
@Test
111+
public void testSliderDrag_ListenerShouldBeCalled() {
112+
// Drag starts from one quarter to the left end.
113+
touchSliderAtValue(slider, SLIDER_VALUE_FROM + SLIDER_VALUE_RANGE / 4, MotionEvent.ACTION_DOWN);
114+
// Drags to the center.
115+
for (int incremental = 1; incremental <= 100; incremental++) {
116+
touchSliderAtValue(
117+
slider,
118+
SLIDER_VALUE_FROM + SLIDER_VALUE_RANGE * (25 + incremental / 4) / 100,
119+
MotionEvent.ACTION_MOVE);
120+
}
121+
// Drag released.
122+
touchSliderAtValue(slider, SLIDER_VALUE_TO / 2, MotionEvent.ACTION_UP);
123+
// Verifies listener calls.
124+
for (int value = 25; value <= 50; value++) {
125+
verify(mockOnChangeListener, times(1))
126+
.onValueChange(eq(slider), AdditionalMatchers.eq((float) value, FLOAT_ERROR), eq(true));
127+
}
128+
}
129+
130+
@Test
131+
public void testSliderSetValue_OnlySetOnce() {
132+
// Sets value twice.
133+
slider.setValue(SLIDER_VALUE_FROM + SLIDER_VALUE_RANGE / 2);
134+
slider.setValue(SLIDER_VALUE_FROM + SLIDER_VALUE_RANGE / 2);
135+
136+
// Verifies listener calls.
137+
verify(mockOnChangeListener, times(1))
138+
.onValueChange(eq(slider), eq(SLIDER_VALUE_FROM + SLIDER_VALUE_RANGE / 2), eq(false));
139+
}
140+
141+
private static void touchSliderAtValue(Slider s, float value, int motionEventType) {
142+
float x =
143+
s.getTrackSidePadding()
144+
+ (value - s.getValueFrom()) / (s.getValueTo() - s.getValueFrom()) * s.getTrackWidth();
145+
float y = s.getY() + s.getHeight() / 2;
146+
147+
s.dispatchTouchEvent(
148+
MotionEvent.obtain(
149+
SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), motionEventType, x, y, 0));
150+
}
151+
}

0 commit comments

Comments
 (0)