-
Notifications
You must be signed in to change notification settings - Fork 39
/
SegmentedButtonGroup.java
1679 lines (1470 loc) · 69.6 KB
/
SegmentedButtonGroup.java
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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package com.addisonelliott.segmentedbutton;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.Outline;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.os.Build;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.AnticipateInterpolator;
import android.view.animation.AnticipateOvershootInterpolator;
import android.view.animation.BounceInterpolator;
import android.view.animation.CycleInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import android.view.animation.LinearInterpolator;
import android.view.animation.OvershootInterpolator;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import androidx.annotation.ColorInt;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
import androidx.interpolator.view.animation.FastOutLinearInInterpolator;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
public class SegmentedButtonGroup extends LinearLayout
{
// region Variables & Constants
private static final String TAG = "SegmentedButtonGroup";
// Animation interpolator styles for animating changing the selected button
public final static int ANIM_INTERPOLATOR_NONE = -1;
public final static int ANIM_INTERPOLATOR_FAST_OUT_SLOW_IN = 0;
public final static int ANIM_INTERPOLATOR_BOUNCE = 1;
public final static int ANIM_INTERPOLATOR_LINEAR = 2;
public final static int ANIM_INTERPOLATOR_DECELERATE = 3;
public final static int ANIM_INTERPOLATOR_CYCLE = 4;
public final static int ANIM_INTERPOLATOR_ANTICIPATE = 5;
public final static int ANIM_INTERPOLATOR_ACCELERATE_DECELERATE = 6;
public final static int ANIM_INTERPOLATOR_ACCELERATE = 7;
public final static int ANIM_INTERPOLATOR_ANTICIPATE_OVERSHOOT = 8;
public final static int ANIM_INTERPOLATOR_FAST_OUT_LINEAR_IN = 9;
public final static int ANIM_INTERPOLATOR_LINEAR_OUT_SLOW_IN = 10;
public final static int ANIM_INTERPOLATOR_OVERSHOOT = 11;
// Interface defined for linting purposes to ensure that an animation interpolator value (integer type) is one
// of the valid values
@Retention(RetentionPolicy.SOURCE)
@IntDef({
ANIM_INTERPOLATOR_NONE, ANIM_INTERPOLATOR_FAST_OUT_SLOW_IN, ANIM_INTERPOLATOR_BOUNCE,
ANIM_INTERPOLATOR_LINEAR, ANIM_INTERPOLATOR_DECELERATE, ANIM_INTERPOLATOR_CYCLE,
ANIM_INTERPOLATOR_ANTICIPATE, ANIM_INTERPOLATOR_ACCELERATE_DECELERATE, ANIM_INTERPOLATOR_ACCELERATE,
ANIM_INTERPOLATOR_ANTICIPATE_OVERSHOOT, ANIM_INTERPOLATOR_FAST_OUT_LINEAR_IN,
ANIM_INTERPOLATOR_LINEAR_OUT_SLOW_IN, ANIM_INTERPOLATOR_OVERSHOOT
})
public @interface AnimationInterpolator {}
// This ViewGroup consists of a FrameLayout as it's child which contains three items:
// 1. Button LinearLayout that contains the SegmentedButtons
// 2. Divider LinearLayout that contains the dividers between buttons
// 3. Border view that has the border for the group that is drawn over everything else
private LinearLayout buttonLayout;
// Purpose of the divider LinearLayout is to ensure the button dividers are placed in between the buttons and
// that no extra space is allocated for the divider. If dividers were placed on the buttonLayout instead, then space
// would be allocated for the dividers and the biggest problem is that the background ends up being gray if there is
// a divider padding set.
// This becomes difficult to solve since each button can have its individual background, much easier to just put
// the dividers in between the buttons and allocate no space
private LinearLayout dividerLayout;
// View for placing border on top of the buttons
// Background view for placing border on top of the buttons, background is transparent to see everything but border
private EmptyView borderView;
// Array containing the buttons
private ArrayList<SegmentedButton> buttons;
// Drawable for the background, this will be a ColorDrawable in case a solid color is given
private Drawable backgroundDrawable;
// Drawable for the background when selected, this will be a ColorDrawable in case a solid color is given
private Drawable selectedBackgroundDrawable;
// Width of the border in pixels (default value is 0px for no border)
private int borderWidth;
// Color of the border (default color is black)
private int borderColor;
// Width of the dash for border, in pixels. Value of 0px means solid line (default is 0px)
private int borderDashWidth;
// Width of the gap for border, in pixels. Only relevant when dashWidth is greater than 0px
private int borderDashGap;
// This is the border information for the selected button with the same defaults as above for the button group
// border
private int selectedBorderWidth;
private int selectedBorderColor;
private int selectedBorderDashWidth;
private int selectedBorderDashGap;
// Radius for rounding edges of the button group, in pixels (default value is 0)
private int radius;
// Radius for rounding edges of the selected button, in pixels (default value is 0)
private int selectedButtonRadius;
// Position of the currently selected button, zero-indexed (default value is 0)
// When animating, the position will be the previous value until after animation is finished
private int position;
// Whether or not the button can be dragged to a different position (default value is false)
private boolean draggable;
// When a user touches down on the currently selected button, this is set to be the difference between their
// current X location of tapping and the currently selected button's left X coordinate
// When user drags their finger across the button group, this value will be used to offset the current X location
// with how much the button should have moved
//
// This value will be NaN when dragging is disabled
private float dragOffsetX;
// Whether or not ripple is enabled for animating when any button is pressed (default is true)
private boolean ripple;
// Whether or not a ripple color was specified
private boolean hasRippleColor;
// Color of the ripple to display over the buttons (default value is gray)
private int rippleColor;
// Animation interpolator for animating button movement
// Android has some standard interpolator, e.g. BounceInterpolator, but also easy to create custom interpolator
private Interpolator selectionAnimationInterpolator;
// Duration in milliseconds for animating changing the selected button (default value is 500ms)
private int selectionAnimationDuration;
// Animation used for storing the current animation for changing the selected button
ValueAnimator buttonAnimator;
// Exact position of the currently selected button which includes its location during animation
// The range is from 0.0f to the number of buttons - 1. (i.e. 0.0f -> 2.0f for 3 buttons)
// A value of 2.25 would mean the left side of the selected button is 25% of the 3rd button.
//
// One particular use for saving this variable is in case the user selects a different button mid-animation, it
// allows the animation to go from the current position to the newly selected position
private float currentPosition;
// Value that contains the last location of the left side of the selected button used for animating
// For example, if the currentPosition was 2.25, then the lastPosition would be set to 2
private int lastPosition;
// Listener to notify when position is changed
// Note: Position change will be notified after animations complete because that is when the position actually
// changes
private OnPositionChangedListener onPositionChangedListener;
// endregion
// region Constructor
public SegmentedButtonGroup(Context context)
{
super(context);
init(context, null);
}
public SegmentedButtonGroup(Context context, AttributeSet attrs)
{
super(context, attrs);
init(context, attrs);
}
public SegmentedButtonGroup(Context context, AttributeSet attrs, int defStyleAttr)
{
super(context, attrs, defStyleAttr);
init(context, attrs);
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public SegmentedButtonGroup(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)
{
super(context, attrs, defStyleAttr, defStyleRes);
init(context, attrs);
}
private void init(Context context, @Nullable AttributeSet attrs)
{
// Create and set outline provider for the segmented button group
// This is used to provide an outline for the layout because it may have rounded corners
// The primary benefit to using this is that shadows will follow the contour of the outline rather than the
// rectangular bounds
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP)
setOutlineProvider(new OutlineProvider());
buttons = new ArrayList<>();
// This FrameLayout is used in order to stack the button layout, border view & divider layout on top of each
// other rather than horizontally or vertically like this SegmentedButtonGroup would do(it inherits from
// LinearLayout)
//
// Why have a LinearLayout(SegmentedButtonGroup) with only one child of FrameLayout?
// Although it seems redundant, it is so that SegmentedButton children can be specified in the layout XML of
// the SegmentedButtonGroup with layout weight parameters. If SegmentedButtonGroup subclassed FrameLayout,
// the layout weight would be ignored even though the SegmentedButtons are passed to buttonLayout.
FrameLayout container = new FrameLayout(getContext());
// Call super addView so that we do not trigger an Exception since only SegmentedButton instances can be
// added to this view
super.addView(container, -1, new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT));
// Layout that contains all SegmentedButton's
buttonLayout = new LinearLayout(getContext());
buttonLayout.setLayoutParams(new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT,
LayoutParams.WRAP_CONTENT));
buttonLayout.setOrientation(LinearLayout.HORIZONTAL);
container.addView(buttonLayout);
// Create border view
// This is essentially a dummy view that is drawn on top of the buttonLayout so that the border appears on
// top of them
borderView = new EmptyView(context);
borderView.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
container.addView(borderView);
// Create layout that contains dividers for each button
// This layout will essentially mirror the number of elements, size, weight of each element with the only
// difference being that the elements will be transparent and that a divider will be placed between each one
//
// The benefit to placing the dividers in this dummy layout is so that the dividers appear on top of the
// buttons without taking up additional width, which is what happens if the dividers are added to the
// buttonLayout
dividerLayout = new LinearLayout(getContext());
dividerLayout.setLayoutParams(new FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT));
dividerLayout.setOrientation(LinearLayout.HORIZONTAL);
dividerLayout.setClickable(false);
dividerLayout.setFocusable(false);
container.addView(dividerLayout);
// Retrieve custom attributes
getAttributes(context, attrs);
}
private void getAttributes(Context context, @Nullable AttributeSet attrs)
{
// According to docs for obtainStyledAttributes, attrs can be null and I assume that each value will be set
// to the default
final TypedArray ta = context.getTheme().obtainStyledAttributes(attrs, R.styleable.SegmentedButtonGroup, 0, 0);
// Load background if available, this can be a drawable or a color
// Note: Not well documented but getDrawable will return a ColorDrawable if a color is specified
if (ta.hasValue(R.styleable.SegmentedButtonGroup_android_background))
backgroundDrawable = ta.getDrawable(R.styleable.SegmentedButtonGroup_android_background);
// Load background on selection if available, can be drawable or color
if (ta.hasValue(R.styleable.SegmentedButtonGroup_selectedBackground))
selectedBackgroundDrawable = ta.getDrawable(R.styleable.SegmentedButtonGroup_selectedBackground);
// Note: Must read radius before setBorder call in order to round the border corners!
radius = ta.getDimensionPixelSize(R.styleable.SegmentedButtonGroup_radius, 0);
selectedButtonRadius = ta.getDimensionPixelSize(R.styleable.SegmentedButtonGroup_selectedButtonRadius, 0);
// Setup border for button group
// Width is the thickness of the border, color is the color of the border
// Dash width and gap, if the dash width is not zero will make the border dashed with a ratio between dash
// width and gap
borderWidth = ta.getDimensionPixelSize(R.styleable.SegmentedButtonGroup_borderWidth, 0);
borderColor = ta.getColor(R.styleable.SegmentedButtonGroup_borderColor, Color.BLACK);
borderDashWidth = ta.getDimensionPixelSize(R.styleable.SegmentedButtonGroup_borderDashWidth, 0);
borderDashGap = ta.getDimensionPixelSize(R.styleable.SegmentedButtonGroup_borderDashGap, 0);
// Set the border to the read values, this will set the border values to itself but will create a
// GradientDrawable containing the border
setBorder(borderWidth, borderColor, borderDashWidth, borderDashGap);
// Get border information for the selected button
// Same defaults as the border above, however this border information will be passed to each button so that
// the correct border can be rendered around the selected button
selectedBorderWidth = ta.getDimensionPixelSize(R.styleable.SegmentedButtonGroup_selectedBorderWidth, 0);
selectedBorderColor = ta.getColor(R.styleable.SegmentedButtonGroup_selectedBorderColor, Color.BLACK);
selectedBorderDashWidth = ta.getDimensionPixelSize(R.styleable.SegmentedButtonGroup_selectedBorderDashWidth, 0);
selectedBorderDashGap = ta.getDimensionPixelSize(R.styleable.SegmentedButtonGroup_selectedBorderDashGap, 0);
position = ta.getInt(R.styleable.SegmentedButtonGroup_position, 0);
draggable = ta.getBoolean(R.styleable.SegmentedButtonGroup_draggable, false);
// Update clickable property
// Not updating this property sets the clickable value to false by default but this sets the default to true
// while keeping the clickable value if specified in the layouot XML
setClickable(ta.getBoolean(R.styleable.SegmentedButtonGroup_android_clickable, true));
ripple = ta.getBoolean(R.styleable.SegmentedButtonGroup_ripple, true);
hasRippleColor = ta.hasValue(R.styleable.SegmentedButtonGroup_rippleColor);
rippleColor = ta.getColor(R.styleable.SegmentedButtonGroup_rippleColor, Color.GRAY);
final int dividerWidth = ta.getDimensionPixelSize(R.styleable.SegmentedButtonGroup_dividerWidth, 1);
final int dividerRadius = ta.getDimensionPixelSize(R.styleable.SegmentedButtonGroup_dividerRadius, 0);
final int dividerPadding = ta.getDimensionPixelSize(R.styleable.SegmentedButtonGroup_dividerPadding, 0);
// Load divider value if available, the divider can be either a drawable resource or a color
// Load the TypedValue first and check the type to determine if color or drawable
final TypedValue value = new TypedValue();
if (ta.getValue(R.styleable.SegmentedButtonGroup_divider, value))
{
if (value.type == TypedValue.TYPE_REFERENCE || value.type == TypedValue.TYPE_STRING)
{
// Note: Odd case where Android Studio layout preview editor will fail to display a
// SegmentedButtonGroup with a divider drawable because value.resourceId returns 0 and thus
// ContextCompat.getDrawable will return NullPointerException
// Loading drawable TypedArray.getDrawable or doing TypedArray.getResourceId fixes the problem
if (isInEditMode())
{
setDivider(ta.getDrawable(R.styleable.SegmentedButtonGroup_divider), dividerWidth, dividerRadius,
dividerPadding);
}
else
{
setDivider(ContextCompat.getDrawable(context, value.resourceId), dividerWidth, dividerRadius,
dividerPadding);
}
}
else if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT && value.type <= TypedValue.TYPE_LAST_COLOR_INT)
{
// Divider is a color, value.data is the color value
setDivider(value.data, dividerWidth, dividerRadius, dividerPadding);
}
else
{
// Invalid type for the divider, throw an exception
throw new IllegalArgumentException("Invalid type for SegmentedButtonGroup divider in layout XML "
+ "resource. Must be a color or drawable");
}
}
int selectionAnimationInterpolator = ta.getInt(R.styleable.SegmentedButtonGroup_selectionAnimationInterpolator,
ANIM_INTERPOLATOR_FAST_OUT_SLOW_IN);
setSelectionAnimationInterpolator(selectionAnimationInterpolator);
selectionAnimationDuration = ta.getInt(R.styleable.SegmentedButtonGroup_selectionAnimationDuration, 500);
// Recycle the typed array, required once done using it
ta.recycle();
}
// endregion
// region Layout & Measure
@SuppressLint("ClickableViewAccessibility")
@Override
public void addView(View child, int index, ViewGroup.LayoutParams params)
{
if (child instanceof SegmentedButton)
{
final SegmentedButton button = (SegmentedButton)child;
// New position of the button will be the size of the buttons before the button is added
// For example, if there are 5 buttons, then the indices are 0, 1, 2, 3, 4, so the next index is 5!
final int position = buttons.size();
// Give radius, selected button radius, default background and default selected background to the button
// The default backgrounds will only update the background of the button if there is not a background set
// on that button explicitly
button.setBackgroundRadius(radius);
button.setSelectedButtonRadius(selectedButtonRadius);
button.setDefaultBackground(backgroundDrawable);
button.setDefaultSelectedBackground(selectedBackgroundDrawable);
// Setup listener that detects changes in visibility for the buttons
button._setOnVisibilityChangedListener((button1, visibility) -> {
// Mimic visibility for the corresponding divider (i.e. make visible if parent is visible, invisible
// otherwise)
final int index1 = SegmentedButtonGroup.this.buttonLayout.indexOfChild(button1);
SegmentedButtonGroup.this.dividerLayout.getChildAt(index1).setVisibility(visibility);
// Find the first visible button to the left of this button (or null if none)
SegmentedButton leftButton = null;
for (int i = index1 - 1; i >= 0; --i)
{
final SegmentedButton button_ = buttons.get(i);
if (button_.getVisibility() != GONE)
{
leftButton = button_;
break;
}
}
// Find the first visible button to the right of this button (or null if none)
SegmentedButton rightButton = null;
for (int i = index1 + 1; i < buttons.size(); ++i)
{
final SegmentedButton button_ = buttons.get(i);
if (button_.getVisibility() != GONE)
{
rightButton = button_;
break;
}
}
// Below, we update the buttons leftButton and rightButton properties
// Think of the buttons in the group like a chain, each button knows about the button to the left and
// right of itself.
if (visibility == GONE)
{
// This button is being hidden, we leave the left/right button properties alone because they dont
// matter
//
// Update the "chain" of buttons so that the first visible left button is linked to the first
// visible right button
if (leftButton != null)
{
leftButton.setRightButton(rightButton);
leftButton.setupBackgroundClipPath();
}
// Update the "chain" of buttons so that the first visible right button is linked to the first
// visible left button
if (rightButton != null)
{
rightButton.setLeftButton(leftButton);
rightButton.setupBackgroundClipPath();
}
}
else
{
// This button is being shown again, we update the left/right button to be the first visible ones
button1.setLeftButton(leftButton);
button1.setRightButton(rightButton);
button1.setupBackgroundClipPath();
// Update the "chain" of buttons so that the left button points to this button now
if (leftButton != null)
{
leftButton.setRightButton(button1);
leftButton.setupBackgroundClipPath();
}
// Update the "chain" of buttons so that the right button points to this button now
if (rightButton != null)
{
rightButton.setLeftButton(button1);
rightButton.setupBackgroundClipPath();
}
}
});
// Setup button with ripple if enabled and a color is given
// Otherwise disable ripple on the button if ripple is disabled
// The ripple color is only passed to the buttons if a color is specified, otherwise the default color is
// used from the button itself
if (ripple && hasRippleColor)
{
// Set button ripple color only if a value was given globally
button.setRipple(rippleColor);
}
else if (!ripple)
{
// Disable the ripple on the button
button.setRipple(false);
}
// If this is NOT the first item in the group, then update the previous button and this button with its
// respective right button and left button.
if (position != 0)
{
// Find the first visible button to the left of this button (or null if none)
SegmentedButton leftButton = null;
for (int i = buttons.size() - 1; i >= 0; --i)
{
final SegmentedButton button_ = buttons.get(i);
if (button_.getVisibility() != GONE)
{
leftButton = button_;
break;
}
}
// If there is a visible button to the left, then set it to point to this button if its visible or
// otherwise null to treat it as an end button
if (leftButton != null)
{
leftButton.setRightButton(button.getVisibility() != GONE ? button : null);
// Update background clip path for that button since it may need to add/remove round edges
leftButton.setupBackgroundClipPath();
}
// Always set this button to point to the leftmost button
// In the case this button is not visible, then it does not matter since it wont be drawn
button.setLeftButton(leftButton);
}
// Sets up the background clip path, selected button clip path, and selected button border
button.setupBackgroundClipPath();
button.setupSelectedButtonClipPath();
button.setSelectedButtonBorder(selectedBorderWidth, selectedBorderColor, selectedBorderDashWidth,
selectedBorderDashGap);
// Add the button to the main group instead and store the button in our buttons list
buttonLayout.addView(button, params);
buttons.add(button);
// If the given position to start at is this button, select it
if (this.position == position)
updateSelectedPosition(position);
// Add a divider view to the divider layout that mimics the size of the button
// This view is used as essentially a spacer for the dividers in the divider layout
// The divider view needs to know the divider width in order to offset the width correctly
ButtonActor dividerView = new ButtonActor(getContext());
dividerView.setButton(button);
dividerView.setLayoutParams(new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));
dividerView.setVisibility(button.getVisibility());
Drawable dividerDrawable = dividerLayout.getDividerDrawable();
if (dividerDrawable != null)
dividerView.setDividerWidth(dividerDrawable.getIntrinsicWidth());
dividerLayout.addView(dividerView);
}
else
{
// Not allowed to have children of any type besides SegmentedButton
throw new IllegalArgumentException("Invalid child view for SegmentedButtonGroup. Only SegmentedButton's "
+ "are valid children of the group");
}
}
// endregion
// region Events
/**
* Return button position for corresponding button that contains the X coordinate within its bounds
*
* This returns a whole-number indicating the index of the button that corresponds to the given X coordinate.
*
* @param x X screen coordinate
* @return int representing index of the corresponding button
* @throws IllegalStateException if no button contains the coordinate within its bounds
*/
int getButtonPositionFromX(float x)
{
// Loop through each button
int i = 0;
for (; i < buttons.size(); ++i)
{
final SegmentedButton button = buttons.get(i);
// If x value is less than the right-hand side of the button, this is the selected button
// Note: No need to check the left side of button because we assume each button is directly connected
// from left to right
if (button.getVisibility() != GONE && x <= button.getRight())
break;
}
// Return last button if x value is out of bounds
return Math.min(i, buttons.size() - 1);
}
/**
* Return fractional button position for corresponding button that contains the X coordinate within its bounds
*
* The whole part of the floating number returned indicates the index of the button that contains the coordinate.
* The fractional part of the returned floating number represents the position of the coordinate within the buttons
* bounds relative to its width.
*
* For example, a returned button position of 2.25 would indicate that the coordinate was within the 3rd button
* and 1/4 (25%) of the width of the button.
*
* @param x X screen coordinate
* @return float representing the fractional position of the corresponding button
* @throws IllegalStateException if no button contains the coordinate within its bounds
*/
float getButtonPositionFromXF(float x)
{
// Loop through each button
int i = 0;
for (; i < buttons.size(); ++i)
{
final SegmentedButton button = buttons.get(i);
// If x value is less than the right-hand side of the button, this is the selected button
// Note: No need to check the left side of button because we assume each button is directly connected
// from left to right
if (button.getVisibility() != GONE && x < button.getRight())
return i + (x - button.getLeft()) / button.getWidth();
}
// Return last button if x value is out of bounds
return (float)i;
}
/**
* Handles touch events by the user in order to handle tapping a new button or dragging the selected button
*
* Note: dispatchTouchEvent is used here rather than onTouchEvent to allow for touch events to be handled by the
* SegmentedButtonGroup AND SegmentedButton. SegmentedButton needs onTouch events in order to correctly
* displaying ripple animations on tap.
*
* This cannot be done with onTouchEvent because one View must intercept and consume the events. This method
* handles the touch event and then passes to the SegmentedButton to consume the touch event.
*
* @hide
*/
@Override
public boolean dispatchTouchEvent(final MotionEvent ev)
{
// Do not handle touch events if the view is disabled or not clickable
// Oddly enough, the enabled and clickable states don't do anything unless specifically programmed into the
// custom views
if (!isEnabled() || !isClickable())
return false;
switch (ev.getAction())
{
case MotionEvent.ACTION_UP:
{
// Selected button position
final int position = getButtonPositionFromX(ev.getX());
// Go to the selected button on touch up and animate too
//
// In the case that the user has been dragging the button, this will have the effect of "snapping" to
// the nearest button
setPosition(position, true);
// Enable scroll touch event interception again now that we're done dragging
requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_DOWN:
{
// Selected button position
final int position = getButtonPositionFromX(ev.getX());
// If button cannot be dragged, user is NOT pressing the currently selected button or the button is
// being animated, then just set drag offset to NaN meaning drag is not activated
if (!draggable || this.position != position || (buttonAnimator != null && buttonAnimator.isRunning()))
{
dragOffsetX = Float.NaN;
break;
}
// Since the user is now officially dragging the button, we want to disable scrolling interception
// of touch events
requestDisallowInterceptTouchEvent(true);
// Set drag offset in case user moves finger to initiate drag
// Drag offset is the difference between finger current X location and the location of the selected
// button
//
// Used in touch move for calculating the location of the button. Think of it as a delta. If the user
// moves their finger 50px, then the button should move 50px. Thus, this delta will be subtracted
// from the user's X value to get the location of where the button position should be.
dragOffsetX = ev.getX() - buttons.get(position).getLeft();
// Return here so that the touch event is not sent to the buttons
// This prevents the ripple effect from showing up when dragging
return true;
}
case MotionEvent.ACTION_MOVE:
{
// Only drag if drag has been activated and hence is allowed
if (Float.isNaN(dragOffsetX))
break;
// Get X coordinate of where the selected button should be by taking user's X location and subtract
// the offset
float xCoord = ev.getX() - dragOffsetX;
// Convert X coordinate to a position containing the relative offset within the button as well
// Clip the position to be between 0.0f and buttons size minus 1 so that the user doesn't drag out of
// bounds
final float newPosition = Math.min(Math.max(getButtonPositionFromXF(xCoord), 0.0f), buttons.size() - 1);
// Update the selected button to the new position
moveSelectedButton(newPosition);
}
break;
case MotionEvent.ACTION_CANCEL:
{
// Cancel action is called when user leaves the view with their finger and another view captures the
// actions (e.g. scroll views for example)
// In this case, stop dragging and "snap" to nearest position
if (!Float.isNaN(dragOffsetX))
{
setPosition(Math.round(currentPosition), true);
// Enable scroll touch event interception again now that we're done dragging
requestDisallowInterceptTouchEvent(false);
}
}
break;
}
// Pretend like we never handled the touch event and pass to children (SegmentedButton)
return super.dispatchTouchEvent(ev);
}
/**
* Move the selected button to a new position, used for animating and dragging the selected button
*
* The position parameter is a relative index with the whole part of the number indicating the button index and the
* fractional part of the number indicating the relative position of the starting point of the selected button.
*
* For example, a position of 2.25 would indicate that the selected button should START, i.e. the left side of
* the selected button, at 1/4 of the width of the 3rd button.
*/
private void moveSelectedButton(final float position)
{
// Update current position to be the animated value
// This is a float value indicating where the left-side of the button is located
// For example, a currentPosition of 1.0 would mean all of button 1 was selected
// But a currentPosition of 1.5 would mean half of button 1 was selected and half of button 2 (its in
// between animating the value)
currentPosition = position;
// Get the current button position and extract the offset. For example, a currentPosition of 2.25 would
// result in a currentButtonPosition of 2 and the currentOffset to 0.25.
final int currentButtonPosition = (int)currentPosition;
final float currentOffset = currentPosition - currentButtonPosition;
// Get the current button end position, which will start at the current button plus 1 because the width of the
// selected button is 1. Check each button to the right for the first one that is not GONE
int currentEndButtonPosition = currentButtonPosition + 1;
while (currentEndButtonPosition < buttons.size()
&& buttons.get(currentEndButtonPosition).getVisibility() == GONE)
{
++currentEndButtonPosition;
}
// Grab the current button from the position and clip the right side of the button to show the appropriate
// offset
//
// For example, if the currentOffset was 0.25, this means that the left-side of the selected button should
// start at 0.25. Clipping at 0.25 means the right 75% of the button would be showing which is as expected. The
// offset given to the current button is 0.0f -> 1.0f and represents the relative X position to clip the
// button at (going all the way to the right side of the button, i.e. 0.25 all the way to 1.0)
final SegmentedButton currentButton = buttons.get(currentButtonPosition);
currentButton.clipRight(currentOffset);
// For the end button, we want to clip the left side of the button to match up with the right side of the
// previous button. However, there is a slight chance the end button position might be out of range so we
// check if it is first (do nothing if out of range, nothing to clip on left side of)
if (currentEndButtonPosition >= 0 && currentEndButtonPosition < buttons.size())
{
// Grab the button directly to the right of the current button and clip the left
final SegmentedButton currentEndButton = buttons.get(currentEndButtonPosition);
currentEndButton.clipLeft(currentOffset);
}
// lastPosition is the last button position (whole integer) that the selected button began at in the last
// animation frame.
// When the selected button moves from one button to the next, we need to hide the old button somehow.
// This will do that by checking if the last button position is not equal to the current button position or
// the end button position (where the selected button ends = currentButtonPosition + 1). If not equal, then
// that means we are done showing the selected view on this button so we clip the entire view to just show
// the normal button
if (lastPosition != currentButtonPosition && lastPosition != currentEndButtonPosition)
{
buttons.get(lastPosition).clipRight(1.0f);
}
// Repeat same process above but check with where the last button position ended. Note, this last end position
// is the next VISIBLE button, so we start at 1 plus the last position because the width of the selected button
// is 1
int lastEndPosition = lastPosition + 1;
while (lastEndPosition < buttons.size() && buttons.get(lastEndPosition).getVisibility() == GONE)
{
++lastEndPosition;
}
// Clip any views like explained above
if (lastEndPosition != currentEndButtonPosition && lastEndPosition != currentButtonPosition
&& lastEndPosition < buttons.size())
buttons.get(lastEndPosition).clipRight(1.0f);
// Update the lastPosition for the next animation frame
lastPosition = currentButtonPosition;
// Notify to redraw buttons
invalidate();
}
/**
* Update the currently selected position
*
* This will "refresh" the buttons, so to speak, such that they accurately show the correct selected button.
* During animation and dragging operations, there is the chance that intermediate buttons are showing parts of
* the selected view. This shouldn't happen, but if it does this will refresh it to how it should look.
*
* In addition, the onPositionChangedListener will be called with the updated position.
*/
private void updateSelectedPosition(final int position)
{
// Update position, current position and last position to the desired value
this.position = position;
this.currentPosition = position;
this.lastPosition = position;
// Loop through each button and reset it to the appropriate value
for (int i = 0; i < buttons.size(); ++i)
{
final SegmentedButton button = buttons.get(i);
if (i == position)
{
// Show entire selected view
button.clipRight(0.0f);
}
else
{
// Hide entire selected view
button.clipRight(1.0f);
}
}
// Notify listener of position change
if (onPositionChangedListener != null)
onPositionChangedListener.onPositionChanged(position);
}
// endregion
// region Save & Restore State
@Override
protected Parcelable onSaveInstanceState()
{
Bundle bundle = new Bundle();
bundle.putParcelable("superState", super.onSaveInstanceState());
// Save position of selected button
bundle.putInt("position", position);
return bundle;
}
@Override
protected void onRestoreInstanceState(final Parcelable state)
{
// On off chance that the state is not a Bundle, just pass it to the parent class
// Not sure why this would ever happen, but prevents a casting exception
if (!(state instanceof Bundle))
{
super.onRestoreInstanceState(state);
return;
}
final Bundle bundle = (Bundle)state;
// Restore position of selected button
final int position = bundle.getInt("position");
setPosition(position, false);
super.onRestoreInstanceState(bundle.getParcelable("superState"));
}
// endregion
// region Getters & Setters
/**
* List of segmented buttons that are attached to this button group
*/
public ArrayList<SegmentedButton> getButtons()
{
return buttons;
}
/**
* Get segmented button at specified index
*
* Segmented buttons are indexed according to their order of being added to this group
*/
public SegmentedButton getButton(int index)
{
return buttons.get(index);
}
/**
* Returns the background drawable that is the 'global' value for each of the buttons. This is the background
* that is shown when the button is not selected
*
* In the case a solid color background is used, this will be a ColorDrawable
*
* Note: This value is just passed down to each individual SegmentedButton when added to the view. This value can
* be overridden on a per-button basis.
*
* @return the current background drawable when the button is not selected
*/
public Drawable getBackground()
{
return backgroundDrawable;
}
/**
* Set the background displayed when a button is not selected to a given drawable for each button
*
* Note: This is a convenience function that sets the background for each individual button.
*
* @param drawable drawable to set the background to
*/
@Override
public void setBackground(final Drawable drawable)
{
backgroundDrawable = drawable;
// Check for non-null buttons because parent class calls setBackground
if (buttons != null)
{
for (SegmentedButton button : buttons)
button.setBackground(drawable);
}
}
/**
* Set the background to be a solid color when a button is not selected for each button
*
* This will create a ColorDrawable or modify the current background if it is a ColorDrawable.
*
* Note: This is a convenience function that sets the background for each individual button.
*
* @param color color to set the background to
*/
public void setBackground(@ColorInt int color)
{
if (backgroundDrawable instanceof ColorDrawable)
{
((ColorDrawable)backgroundDrawable.mutate()).setColor(color);
// Required to update background for the buttons
setBackground(backgroundDrawable);
}
else
{
setBackground(new ColorDrawable(color));
}
}
/**
* Convenience function for setting the background color
*
* This function already exists in the base View class so it is overridden to prevent confusion as to why
* setBackground works but not setBackgroundColor.
*
* @param color color to set the background to
* @see #setBackground(int)
*/
@Override
public void setBackgroundColor(@ColorInt int color)
{
setBackground(color);
}
/**
* Returns the background drawable that is the 'global' value for each of the buttons. This is the background
* that is shown when the button is selected
*
* In the case a solid color background is used, this will be a ColorDrawable
*
* Note: This value is just passed down to each individual SegmentedButton when added to the view. This value can
* be overridden on a per-button basis.
*
* @return the current background drawable when the button is selected
*/
public Drawable getSelectedBackground()
{
return selectedBackgroundDrawable;
}
/**
* Set the background displayed when a button is selected to a given drawable for each button
*
* Note: This is a convenience function that sets the background for each individual button.
*
* @param drawable drawable to set the background to