-
Notifications
You must be signed in to change notification settings - Fork 40
Expand file tree
/
Copy pathSegmentedButton.java
More file actions
2065 lines (1802 loc) · 78.5 KB
/
SegmentedButton.java
File metadata and controls
2065 lines (1802 loc) · 78.5 KB
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.annotation.SuppressLint;
import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.DashPathEffect;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Path.Direction;
import android.graphics.PointF;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Shader.TileMode;
import android.graphics.Typeface;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.RippleDrawable;
import android.graphics.drawable.VectorDrawable;
import android.os.Build;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.text.Layout;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import androidx.annotation.ColorInt;
import androidx.annotation.FloatRange;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.res.ResourcesCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat;
import codetail.graphics.drawables.DrawableHotspotTouch;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@SuppressWarnings("unused")
@SuppressLint("RtlHardcoded")
public class SegmentedButton extends View
{
// region Variables & Constants
private static final String TAG = "SegmentedButton";
// Bitmap used for creating bitmaps from the background & selected background drawables
private static final Bitmap.Config BITMAP_CONFIG = Bitmap.Config.ARGB_8888;
// Intrinsic size (width & height) to use for creating a Bitmap from a ColorDrawable
// A ColorDrawable has no intrinsic size on its own, so this size is used instead
private static final int COLORDRAWABLE_SIZE = 2;
@IntDef(flag = true, value = {
Gravity.LEFT,
Gravity.RIGHT,
Gravity.TOP,
Gravity.BOTTOM,
})
@Retention(RetentionPolicy.SOURCE)
private @interface GravityOptions {}
// General purpose rectangle to prevent memory allocation in onDraw
private RectF rectF;
// General purpose path to prevent memory allocation in onDraw
private Path path;
// Text paint variable contains paint info for unselected and selected text
private TextPaint textPaint;
// Static layout used for positioning and drawing unselected and selected text
private StaticLayout textStaticLayout;
// Maximum text width assuming all text is on one line, this is used in onMeasure to calculate the desired width
private int textMaxWidth;
// Position (X/Y) of the text and drawable
private PointF textPosition, drawablePosition;
// Clip path used to round background drawable edges to create rounded button group
private Path backgroundClipPath;
// Radius of the segmented button group used for creating background clip path
private int backgroundRadius;
// The button directly to the left and right of this current button
// Preferably segmented buttons shouldn't NEED to know about the buttons beside it, but there is a special case
// where its required
// In addition, this is used to determine whether this button is the left-most or right-most in the group so the
// correct side can be rounded out (if a background radius is specified)
private SegmentedButton leftButton, rightButton;
// Paint objects used for drawing the background and selected background drawables with rounded corners if desired
// The background paint object will only be used if a drawable is present and the background radius is greater
// than 0 (meaning there is rounded corners). Similarly, for the selected background, if a drawable is present
// and the background radius is greater than 0 OR there is a selected button radius.
// Paint objects will contain a BitmapShader that is linked to a Bitmap created from the respective drawables
//
// Note: The BitmapShader approach is used rather than Canvas.clipPath because antialiasing is supported in the
// former but not the latter
private Paint backgroundPaint;
private Paint selectedBackgroundPaint;
// Radius of the selected button used for creating a rounded selected button
private int selectedButtonRadius;
// Corner radii for the selected button, this contains 8x values all set to selectedButtonRadius
// This is used to prevent allocation in the onDraw method
private float[] selectedButtonRadii;
// Paint information for how the border should be drawn for the selected button, null indicates no border
private Paint selectedButtonBorderPaint;
// Horizontal relative clip position from 0.0f to 1.0f.
// Value is scaled by the width of this view to get the actual clip X coordinate
private float relativeClipPosition;
// Whether or not the clipping is occurring from the left (true) or right (false). In simpler terms, if true,
// then the start clipping relative position is 0.0f, otherwise, if clipping from the right, the position is 1.0f
private boolean isClippingLeft;
// Drawable for the background, this will be a ColorDrawable in the case a solid color is given
private Drawable backgroundDrawable;
// Drawable for the background when selected, this will be a ColorDrawable in the case a solid color is given
private Drawable selectedBackgroundDrawable;
// Should button have rounded corners regardless of position
private boolean rounded;
// Color of the ripple to display over the button (default value is gray)
private int rippleColor;
// RippleDrawable is used for drawing ripple animation when tapping buttons on Lollipop and above devices (API 21+)
private RippleDrawable rippleDrawableLollipop;
// Backport for RippleDrawable for API 16-20 devices
private codetail.graphics.drawables.RippleDrawable rippleDrawable;
// Color filters used for tinting the button drawable in normal and when button is selected, will be null for no
// tint
private PorterDuffColorFilter drawableColorFilter, selectedDrawableColorFilter;
// Drawable to draw for the button. Can be drawn beside text or without text at all
private Drawable drawable;
// Padding for the drawable in pixels, this will only be applied between the drawable and text (default value is 0)
private int drawablePadding;
// Whether or not there is a tint color for the drawable when unselected and/or selected
private boolean hasDrawableTint, hasSelectedDrawableTint;
// Tint color for the drawable when unselected and selected
private int drawableTint, selectedDrawableTint;
// Whether or not a width or height was specified for the drawable
private boolean hasDrawableWidth, hasDrawableHeight;
// Width and height for the drawable, in pixels
private int drawableWidth, drawableHeight;
// Determines where to draw the drawable in relation to the text, can be one of GravityOptions types
private int drawableGravity;
// Whether or not we have text, false indicates text should be empty
private boolean hasText;
// Text to display for button (default value is an empty string meaning no text will be shown)
private String text;
// Whether or not we have a selected text color
private boolean hasSelectedTextColor;
// Text color and selected text color (default value is gray for unselected, white for selected text colors)
private int textColor, selectedTextColor;
// Font size of the text in pixels (default value is 14sp)
private float textSize;
// Lines count in StaticLayout, multiline by default
private int linesCount;
// Truncation type of segmented button text, not ellipsized by default
private TextUtils.TruncateAt ellipsize;
// Typeface for displaying the text and selected text, created from the fontFamily & textStyle attributes. Default value for selected is the text typeface.
private Typeface textTypeface, selectedTextTypeface;
// Internal listener that is called when the visibility of this button is changed
private OnVisibilityChangedListener onVisibilityChangedListener;
// endregion
// region Constructor
public SegmentedButton(Context context)
{
super(context);
init(context, null);
}
public SegmentedButton(Context context, AttributeSet attrs)
{
super(context, attrs);
init(context, attrs);
}
public SegmentedButton(Context context, AttributeSet attrs, int defStyleAttr)
{
super(context, attrs, defStyleAttr);
init(context, attrs);
}
private void init(Context context, @Nullable AttributeSet attrs)
{
// Retrieve custom attributes
getAttributes(context, attrs);
initText();
initDrawable();
// Setup default values for clip position
// By default, set to clip from left and have none of the selected view shown
relativeClipPosition = 0.0f;
isClippingLeft = true;
// Setup background clip path parameters
// This should be changed before onDraw is ever called but they are initialized to be safe
backgroundRadius = 0;
leftButton = null;
rightButton = null;
// Create general purpose rectangle, prevents memory allocation during onDraw
rectF = new RectF();
// Create general purpose path, prevents memory allocation during onDraw
path = new Path();
// Required in order for this button to 'consume' the ripple touch event
setClickable(true);
}
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
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.SegmentedButton);
// Load background if available, this can be a drawable or a color
// In the instance of a color, a ColorDrawable is created and used instead
// Note: Not well documented but getDrawable will return a ColorDrawable if a color is specified
if (ta.hasValue(R.styleable.SegmentedButton_android_background))
backgroundDrawable = ta.getDrawable(R.styleable.SegmentedButton_android_background);
// Load background on selection if available, can be drawable or color
if (ta.hasValue(R.styleable.SegmentedButton_selectedBackground))
selectedBackgroundDrawable = ta.getDrawable(R.styleable.SegmentedButton_selectedBackground);
rounded = ta.getBoolean(R.styleable.SegmentedButton_rounded, false);
// Parse ripple color value and update the ripple
setRipple(ta.getColor(R.styleable.SegmentedButton_rippleColor, Color.GRAY));
// Load drawable if available, otherwise variable will be null
if (ta.hasValue(R.styleable.SegmentedButton_drawable))
{
int drawableResId = ta.getResourceId(R.styleable.SegmentedButton_drawable, -1);
drawable = readCompatDrawable(context, drawableResId).mutate();
}
drawablePadding = ta.getDimensionPixelSize(R.styleable.SegmentedButton_drawablePadding, 0);
hasDrawableTint = ta.hasValue(R.styleable.SegmentedButton_drawableTint);
drawableTint = ta.getColor(R.styleable.SegmentedButton_drawableTint, -1);
hasSelectedDrawableTint = ta.hasValue(R.styleable.SegmentedButton_selectedDrawableTint);
selectedDrawableTint = ta.getColor(R.styleable.SegmentedButton_selectedDrawableTint, -1);
hasDrawableWidth = ta.hasValue(R.styleable.SegmentedButton_drawableWidth);
hasDrawableHeight = ta.hasValue(R.styleable.SegmentedButton_drawableHeight);
drawableWidth = ta.getDimensionPixelSize(R.styleable.SegmentedButton_drawableWidth, -1);
drawableHeight = ta.getDimensionPixelSize(R.styleable.SegmentedButton_drawableHeight, -1);
drawableGravity = ta.getInteger(R.styleable.SegmentedButton_drawableGravity, Gravity.LEFT);
hasText = ta.hasValue(R.styleable.SegmentedButton_text);
text = ta.getString(R.styleable.SegmentedButton_text);
textColor = ta.getColor(R.styleable.SegmentedButton_textColor, Color.GRAY);
hasSelectedTextColor = ta.hasValue(R.styleable.SegmentedButton_selectedTextColor);
selectedTextColor = ta.getColor(R.styleable.SegmentedButton_selectedTextColor, Color.WHITE);
linesCount = ta.getInt(R.styleable.SegmentedButton_linesCount, Integer.MAX_VALUE);
ellipsize = resolveEllipsizeType(ta.getInt(R.styleable.SegmentedButton_android_ellipsize, 0));
// Convert 14sp to pixels for default value on text size
final float px14sp = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 14.0f,
context.getResources().getDisplayMetrics());
textSize = ta.getDimension(R.styleable.SegmentedButton_textSize, px14sp);
final boolean hasFontFamily = ta.hasValue(R.styleable.SegmentedButton_android_fontFamily);
final int textStyle = ta.getInt(R.styleable.SegmentedButton_textStyle, Typeface.NORMAL);
final int selectedTextStyle = ta.getInt(R.styleable.SegmentedButton_selectedTextStyle, textStyle);
// If a font family is present then load typeface with text style from that
if (hasFontFamily)
{
// Note: TypedArray.getFont is used for Android O & above while ResourcesCompat.getFont is used for below
// Experienced an odd bug in the design viewer of Android Studio where it would not work with only using
// the ResourcesCompat.getFont function. Unsure of the reason but this fixes it
if (VERSION.SDK_INT >= VERSION_CODES.O)
{
textTypeface = Typeface.create(ta.getFont(R.styleable.SegmentedButton_android_fontFamily), textStyle);
selectedTextTypeface = Typeface.create(ta.getFont(R.styleable.SegmentedButton_android_fontFamily), selectedTextStyle);
}
else
{
final int fontFamily = ta.getResourceId(R.styleable.SegmentedButton_android_fontFamily, 0);
if (fontFamily > 0)
{
textTypeface = Typeface.create(ResourcesCompat.getFont(context, fontFamily), textStyle);
selectedTextTypeface = Typeface.create(ResourcesCompat.getFont(context, fontFamily), selectedTextStyle);
}
else
{
// On lower API Android versions, fontFamily returns 0 for default fonts such as "sans-serif" and
// "monospace". Thus, we get the font as a string and then try to load that way
textTypeface = Typeface.create(ta.getString(R.styleable.SegmentedButton_android_fontFamily),
textStyle);
selectedTextTypeface = Typeface.create(ta.getString(R.styleable.SegmentedButton_android_fontFamily),
selectedTextStyle);
}
}
}
else
{
textTypeface = Typeface.create((Typeface)null, textStyle);
selectedTextTypeface = Typeface.create((Typeface)null, selectedTextStyle);
}
ta.recycle();
}
private void initText()
{
// Text position is calculated regardless of if text exists
// Not worth extra effort of not setting two float values
textPosition = new PointF();
// If there is no text then do not bother
if (!hasText)
{
textStaticLayout = null;
return;
}
// Create text paint that will be used to draw the text on the canvas
textPaint = new TextPaint();
textPaint.setAntiAlias(true);
textPaint.setTextSize(textSize);
textPaint.setColor(textColor);
textPaint.setTypeface(textTypeface);
// Initial kickstart to setup the text layout by assuming the text will be all in one line
textMaxWidth = (int)textPaint.measureText(text);
if (Build.VERSION.SDK_INT >= VERSION_CODES.M)
{
textStaticLayout = StaticLayout.Builder
.obtain(text, 0, text.length(), textPaint, textMaxWidth)
.setMaxLines(linesCount)
.setEllipsize(ellipsize)
.build();
}
else
{
textStaticLayout = new StaticLayout(text, textPaint, textMaxWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0,
false);
}
}
private Drawable readCompatDrawable(Context context, int drawableResId)
{
Drawable drawable = AppCompatResources.getDrawable(context, drawableResId);
// API 28 has a bug with vector drawables where the selected tint color is always applied to the drawable
// To prevent this, the vector drawable is converted to a bitmap
if ((VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP && drawable instanceof VectorDrawable)
|| drawable instanceof VectorDrawableCompat)
{
Bitmap bitmap = getBitmapFromVectorDrawable(drawable);
return new BitmapDrawable(context.getResources(), bitmap);
}
else
return drawable;
}
private void initDrawable()
{
// Drawable position is calculated regardless of if drawable exists
// Not worth extra effort of not setting two float values
drawablePosition = new PointF();
// If there is no drawable then do not bother
if (drawable == null)
return;
// If drawable has a tint color, then create a color filter that will be applied to it
if (hasDrawableTint)
drawableColorFilter = new PorterDuffColorFilter(drawableTint, PorterDuff.Mode.SRC_IN);
// If selected drawable has a tint color, then create a color filter that will be applied to it
if (hasSelectedDrawableTint)
selectedDrawableColorFilter = new PorterDuffColorFilter(selectedDrawableTint, PorterDuff.Mode.SRC_IN);
}
// endregion
// region Layout & Measure
@SuppressLint("DrawAllocation")
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
{
// Measured width & height
int width, height;
// Calculate drawable width, 0 if null, drawableWidth if set, otherwise intrinsic width
final int drawableWidth = drawable != null ? hasDrawableWidth ? this.drawableWidth
: drawable.getIntrinsicWidth() : 0;
// For the text width, assume that it is in a single line with no wrapping which would be textMaxWidth
// This variable is used to calculate the desired width and the desire is for it all to be in a single line
final int textWidth = hasText ? textMaxWidth : 0;
// Desired width will always have left & right padding regardless of horizontal/vertical gravity for the
// drawable and text.
int desiredWidth = getPaddingLeft() + getPaddingRight();
if (Gravity.isHorizontal(drawableGravity))
{
// When drawable and text are inline horizontally, then the total desired width is:
// padding left + text width (assume one line) + drawable padding + drawable width + padding right
desiredWidth += textWidth + drawablePadding + drawableWidth;
}
else
{
// When drawable and text are on top of each other, the total desired width is:
// padding left + max(text width, drawable width) + padding right
desiredWidth += Math.max(textWidth, drawableWidth);
}
// Resolve width with measure spec and desired width
// Three options:
// - MeasureSpec.EXACTLY: Set width to exactly specified size
// - MeasureSpec.AT_MOST: Set width to desired size but dont exceed specified size
// - MeasureSpec.UNSPECIFIED: Set width to desired size
width = resolveSize(desiredWidth, widthMeasureSpec);
// With width calculated, recalculate the text parameters to get new height (wrapping may occur)
measureTextWidth(width, drawableWidth);
// Repeat measuring process for height now
// Note that the height is the static layout height which may or may not be multi-lined
// Calculate drawable height, 0 if null, drawableHeight if set, otherwise intrinsic height
final int drawableHeight = drawable != null ? hasDrawableHeight ? this.drawableHeight
: drawable.getIntrinsicHeight() : 0;
final int textHeight = hasText ? textStaticLayout.getHeight() : 0;
int desiredHeight = getPaddingTop() + getPaddingBottom();
if (Gravity.isHorizontal(drawableGravity))
{
// When drawable and text are horizontal, the total desired height is:
// padding left + max(text width, drawable width) + padding right
desiredHeight += Math.max(textHeight, drawableHeight);
}
else
{
// When drawable and text are vertical, then the total desired height is:
// padding left + text width (assume one line) + drawable padding + drawable width + padding right
desiredHeight += textHeight + drawablePadding + drawableHeight;
}
// Resolve height with measure spec and desired height
// Three options:
// - MeasureSpec.EXACTLY: Set height to exactly specified size
// - MeasureSpec.AT_MOST: Set height to desired size but dont exceed specified size
// - MeasureSpec.UNSPECIFIED: Set height to desired size
height = resolveSize(desiredHeight, heightMeasureSpec);
// Required to be called to notify the View of the width & height decided
setMeasuredDimension(width, height);
}
@Override
protected void onSizeChanged(final int w, final int h, final int oldw, final int oldh)
{
super.onSizeChanged(w, h, oldw, oldh);
// Calculate new positions and bounds for text & drawable
updateSize();
// Recalculate the background clip path since width & height have changed
setupBackgroundClipPath();
}
/**
* Create new static text layout with new measured text width based off the total width of the button and the
* drawable width.
*
* This does nothing if the button has no text to display
*
* @param width size, in pixels, of the button
* @param drawableWidth size, in pixels, of the drawable
*/
private void measureTextWidth(int width, int drawableWidth)
{
// If there is no text, then we don't need to do anything
if (!hasText)
return;
// Set drawable width to be the drawable width if the drawable has horizontal gravity, otherwise the drawable
// width doesnt matter
// Text width is equal to the total width minus padding and drawable width
// But, if the maximum text width is smaller, just use that and we will manually pad it later
int newDrawableWidth = Gravity.isHorizontal(drawableGravity) ? drawableWidth : 0;
int textWidth = Math.min(width - getPaddingLeft() - getPaddingRight() - newDrawableWidth, textMaxWidth);
// Odd case where there is not enough space for the padding and drawable width so we just return
if (textWidth < 0)
return;
// Create new static layout with width
// Old way of creating static layout was deprecated but I dont think there is any speed difference between
// the two
if (Build.VERSION.SDK_INT >= VERSION_CODES.M)
{
textStaticLayout = StaticLayout.Builder
.obtain(text, 0, text.length(), textPaint, textWidth)
.setMaxLines(linesCount)
.setEllipsize(ellipsize)
.build();
}
else
{
textStaticLayout = new StaticLayout(text, textPaint, textWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0, false);
}
}
/**
* Calculate new bounds for all elements in the button
*
* This will be called on onSizeChanged, which is called less frequently than onMeasure which will speed things up
*/
private void updateSize()
{
final int width = getWidth(), height = getHeight();
final int textWidth = hasText ? textStaticLayout.getWidth() : 0;
final int textHeight = hasText ? textStaticLayout.getHeight() : 0;
final int drawableWidth = drawable != null ? hasDrawableWidth ? this.drawableWidth
: drawable.getIntrinsicWidth() : 0;
final int drawableHeight = drawable != null ? hasDrawableHeight ? this.drawableHeight
: drawable.getIntrinsicHeight() : 0;
// Calculates the X/Y positions of the text and drawable now that the measured size is known
if (Gravity.isHorizontal(drawableGravity))
{
// Calculate Y position for horizontal gravity, i.e. center the drawable and/or text if necessary
textPosition.y = getPaddingTop()
+ (height - getPaddingTop() - getPaddingBottom() - textHeight) / 2.0f;
drawablePosition.y = getPaddingTop()
+ (height - getPaddingTop() - getPaddingBottom() - drawableHeight) / 2.0f;
// Calculate the starting X position with horizontal gravity
// startPosition is half of the remaining space to center the drawable and text
final float startPosition = (width - textWidth - drawableWidth - drawablePadding) / 2.0f;
// Position the drawable & text based on the gravity
if (drawableGravity == Gravity.LEFT)
{
textPosition.x = startPosition + drawableWidth + drawablePadding;
drawablePosition.x = startPosition;
}
else if (drawableGravity == Gravity.RIGHT)
{
textPosition.x = startPosition;
drawablePosition.x = startPosition + textWidth + drawablePadding;
}
}
else
{
// Calculate X position for vertical gravity, i.e. center the drawable and/or text horizontally if necessary
textPosition.x = getPaddingLeft()
+ (width - getPaddingLeft() - getPaddingRight() - textWidth) / 2.0f;
drawablePosition.x = getPaddingLeft()
+ (width - getPaddingLeft() - getPaddingRight() - drawableWidth) / 2.0f;
// Calculate the starting Y position with vertical gravity
// startPosition is half of the remaining space to center the drawable and text
final float startPosition = (height - textHeight - drawableHeight - drawablePadding) / 2.0f;
// Position the drawable & text based on the gravity
if (drawableGravity == Gravity.TOP)
{
textPosition.y = startPosition + drawableHeight + drawablePadding;
drawablePosition.y = startPosition;
}
else if (drawableGravity == Gravity.BOTTOM)
{
textPosition.y = startPosition;
drawablePosition.y = startPosition + textHeight + drawablePadding;
}
}
// Set bounds of drawable if it exists
if (drawable != null)
{
drawable.setBounds((int)drawablePosition.x, (int)drawablePosition.y,
(int)drawablePosition.x + drawableWidth, (int)drawablePosition.y + drawableHeight);
}
// Set bounds of background drawable if it exists
if (backgroundDrawable != null)
backgroundDrawable.setBounds(0, 0, width, height);
// Set bounds of selected background drawable if it exists
if (selectedBackgroundDrawable != null)
selectedBackgroundDrawable.setBounds(0, 0, width, height);
// Set bounds of ripple drawable if it exists
if (rippleDrawableLollipop != null)
rippleDrawableLollipop.setBounds(0, 0, width, height);
// Set bounds of ripple drawable if it exists
if (rippleDrawable != null)
rippleDrawable.setBounds(0, 0, width, height);
}
// endregion
// region Drawing
@Override
protected void onDraw(Canvas canvas)
{
super.onDraw(canvas);
final int width = getWidth();
final int height = getHeight();
// Draw background (unselected)
if (backgroundDrawable != null)
{
// Draw the background with rounded corners if the background clip path and background paint objet are
// non-null. The background clip path will be present if the background has rounded corners. See
// setupBackgroundClipPath for more details. Ideally the backgroundPaint object will always be present
// when backgroundClipPath is present but there are select cases when the bitmap cannot be generated from
// the drawable because of unknown bounds on program start.
//
// Otherwise, the background is drawn normally via the drawable with no rounded corners
if (backgroundClipPath != null && backgroundPaint != null)
canvas.drawPath(backgroundClipPath, backgroundPaint);
else
backgroundDrawable.draw(canvas);
}
// Draw text (unselected)
if (hasText)
{
canvas.save();
canvas.translate(textPosition.x, textPosition.y);
textPaint.setColor(textColor);
textPaint.setTypeface(textTypeface);
textStaticLayout.draw(canvas);
canvas.restore();
}
// Draw drawable (unselected)
if (drawable != null)
{
drawable.setColorFilter(drawableColorFilter);
drawable.draw(canvas);
}
// Begin drawing selected button view
canvas.save();
// Clip canvas for drawing selected button items
// The relativeClipPosition and isClippingLeft is used to clip part of the selected button view to allow for
// smooth animation between one button to the next
//
// If isClippingLeft is true, then the left side of the selected button is being clipped (i.e. shown) and the
// right side is hidden. If isClippingLeft is false, then the right side of the selected button is being
// clipped and the left side is hidden.
//
// The amount of the left or right side being shown is based on the relativeClippingPosition, a value from
// 0.0f to 1.0f representing the relative position on the button.
if (isClippingLeft)
{
// If clipping the left, then relativeClipPosition * width represents the right side of the selected
// button that is shown/clipped.
//
// The left side of the clip rectangle is set to be the relative clip position minus 1.0f times the width
// of the button directly to the left of this button. This will be a negative value (relativeClipPosition
// ranges from 0.0f to 1.0f, so subtracting 1.0f will make it range from -1.0f to 0.0f) and is scaled by
// the button width directly to the left of this button. The width of this button may not be the same as
// the one to the left so this is necessary.
//
// The reason the left side is set to a negative value as opposed to just 0.0f is because it is necessary
// for a smooth animation when the selected button has rounded corners (i.e. selectedButtonRadius > 0).
// Without the negative left clip side, the rounded corners will not smoothly transition from the button
// to the left to this button.
//
// For the left-most button, the left button width is set to be the width of this button because it
// doesn't matter.
final float leftButtonWidth = isLeftButton() ? width : leftButton.getWidth();
rectF.set((relativeClipPosition - 1.0f) * leftButtonWidth, 0.0f, relativeClipPosition * width, height);
}
else
{
// Otherwise, if clipping the right, then the relativeClipPosition * width represents the left side of
// the selected button that is shown/clipped.
//
// The right side of the clip rectangle is set to be the width plus the relativeClipPosition times the
// width of the button directly to the right of this button. Note that the width of the button to the
// right may not be the same as the width of this button.
//
// The reason the right side is set to a value greater than the width as opposed to just the width itself
// is because it is necessary for a smooth animation when the selected button has rounded corners (i.e.
// selectedButtonRadius > 0). Without the correct right clip side, the rounded corners will not smoothly
// transition from the button to the right to this button.
final float rightButtonWidth = isRightButton() ? width : rightButton.getWidth();
rectF.set(relativeClipPosition * width, 0.0f, width + relativeClipPosition * rightButtonWidth, height);
}
// Clip canvas for drawing the selected button view
// Allows for smooth animation between one button to the next
canvas.clipRect(rectF);
// Draw background (selected)
//
// Draw the selected background with rounded corners in two cases:
// 1. Selected button has rounded corners (i.e. selectedButtonRadius > 0)
// 2. Background has a radius (i.e. backgroundRadius > 0)
// In these two cases, the background is drawn using a BitmapShader contained in the background paint object/
// Otherwise, the background is drawn normally via the drawable with no rounded corners.
if (selectedButtonRadius > 0 && selectedBackgroundPaint != null)
{
path.reset();
path.addRoundRect(rectF, selectedButtonRadii, Direction.CW);
canvas.drawPath(path, selectedBackgroundPaint);
}
else if (backgroundClipPath != null && selectedBackgroundPaint != null)
{
canvas.drawPath(backgroundClipPath, selectedBackgroundPaint);
}
else if (selectedBackgroundDrawable != null)
{
selectedBackgroundDrawable.draw(canvas);
}
// Draw text (selected)
if (hasText)
{
canvas.save();
canvas.translate(textPosition.x, textPosition.y);
// If a selected text color was specified, then use that, otherwise we want to default to the original
// text color
textPaint.setColor(hasSelectedTextColor ? selectedTextColor : textColor);
textPaint.setTypeface(selectedTextTypeface);
textStaticLayout.draw(canvas);
canvas.restore();
}
// Draw drawable (selected)
if (drawable != null)
{
// If a selected drawable tint was used, then use that, but if it wasn't specified we want to stick with
// the normal tint color.
drawable.setColorFilter(hasSelectedDrawableTint ? selectedDrawableColorFilter : drawableColorFilter);
drawable.draw(canvas);
}
// Draw a border around the selected button
if (selectedButtonBorderPaint != null)
{
// Get the border width from the paint information and divide by 2
// Remember that rectF is the rectangle that was setup for the appropriate clip path above
// Note that this rectangle should NOT be touched after the clip path is set otherwise the border drawn
// will be incorrect.
//
// The rectangle is inset by half of the border width because the border width is centered about the
// rectangle bounds resulting in half of the border being cut off since it is outside the clip path. In
// addition, the inset is reduced by half a pixel (0.5f) to ensure there is no antialiasing bleed through
// around the edge of the border.
final float halfBorderWidth = selectedButtonBorderPaint.getStrokeWidth() / 2.0f;
rectF.inset(halfBorderWidth - 0.5f, halfBorderWidth - 0.5f);
// Note: A path is used here rather than canvas.drawRoundRect because there was odd behavior on API 19
// and particular devices where the border radius did not match the background radius.
path.reset();
path.addRoundRect(rectF, selectedButtonRadii, Direction.CW);
canvas.drawPath(path, selectedButtonBorderPaint);
}
canvas.restore();
canvas.save();
// Clip to the background clip path if available
// This is used so the ripple effect will stop at the rounded corners of the background
if (backgroundClipPath != null)
{
canvas.clipPath(backgroundClipPath);
}
// Draw ripple drawable to show ripple effect on click
if (rippleDrawableLollipop != null)
{
rippleDrawableLollipop.draw(canvas);
}
// Draw ripple drawable to show ripple effect on click
if (rippleDrawable != null)
{
rippleDrawable.draw(canvas);
}
canvas.restore();
}
/**
* Horizontally clips selected button view from the left side (0.0f) to relativePosition
*
* For example, a relativePosition of 1.0f would mean the entire selected button view would be available and no
* clipping would occur.
*
* However, a relative position of 0.0f would mean the entire selected button view is clipped and the normal
* button view is entirely visible.
*
* This can be thought of as the selected button view being clipped from 0.0f on the left to the relativePosition
* with 1.0f being all the way on the right.
*
* @param relativePosition Position from 0.0f to 1.0f that represents where to end clipping. A value of 0.0f
* would represent no clipping and 1.0f would represent clipping the entire view
*/
void clipLeft(@FloatRange(from = 0.0, to = 1.0) float relativePosition)
{
// Clipping from the left side, set to true
isClippingLeft = true;
// Update relative clip position
relativeClipPosition = relativePosition;
// Redraw
invalidate();
}
/**
* Horizontally clips selected button view from the right side (1.0f) to relativePosition
*
* For example, a relativePosition of 0.0f would mean the entire selected button view would be available and no
* clipping would occur.
*
* However, a relative position of 1.0f would mean the entire selected button view is clipped and the normal
* button view is entirely visible.
*
* This can be thought of as the selected button view being clipped from 0.0f on the left to the relativePosition
* with 1.0f being all the way on the right.
*
* @param relativePosition Position from 0.0f to 1.0f that represents where to end clipping. A value of 1.0f
* would represent no clipping and 0.0f would represent clipping the entire view
*/
void clipRight(@FloatRange(from = 0.0, to = 1.0) float relativePosition)
{
// Clipping from the right side, set to false
isClippingLeft = false;
// Update relative clip position
relativeClipPosition = relativePosition;
// Redraw
invalidate();
}
// endregion
// region Ripple-related
/**
* Updates hotspot for drawable
*
* This function is called by the base View class when the user taps on a location. The base View class handles
* this automatically for the background drawable. The primary advantage of this function and a hotspot in
* general is for the ripple effect to show where the ripple show originate from.
*
* Updates the hotspot for the ripple drawable manually since the base View class does not know about the ripple
* drawable.
*
* @param x X coordinate of the new hotspot
* @param y Y coordinate of the new hotspot
*/
@SuppressLint("NewApi")
@Override
public void drawableHotspotChanged(final float x, final float y)
{
super.drawableHotspotChanged(x, y);
// Update the hotspot for the ripple drawable
if (rippleDrawableLollipop != null)
rippleDrawableLollipop.setHotspot(x, y);
}
/**
* Updates state for drawable
*
* This function is called by the base View class when the state of the View changes. The state of the View is
* how state-lists and the ripple drawable work, by monitoring the state for a change in the pressed state and
* having different drawable states or actions when the state changes.
*
* The base View class handles updating the state for the background drawable but the ripple drawable state is
* updated manually here since the base View class does not know about the ripple drawable.
*/
@Override
protected void drawableStateChanged()
{
super.drawableStateChanged();
// Update the state for the ripple drawable
if (rippleDrawableLollipop != null)
rippleDrawableLollipop.setState(getDrawableState());
// Update the state for the ripple drawable
if (rippleDrawable != null)
rippleDrawable.setState(getDrawableState());
}
/**
* Validate Drawables and whether or not they are allowed to animate
*
* By returning true for a Drawable, this will allow animations to be scheduled for that Drawable, which is
* relevant for the ripple drawables in this class.
*
* @param who Drawable to verify. Return true if this class is displaying the drawable.
* @return Returns true if the drawable is being displayed in this view, else false and it is not allowed to
* animate
*/
@Override
protected boolean verifyDrawable(@NonNull final Drawable who)
{
// Very obscure and difficult to find but it is noted in the source code docstring for this function
// Return true if the drawable is the ripple drawable (backport or regular)
// Normally the super class handles this automatically for the background drawable but the ripple drawable is
// not the background in this instance
return who == rippleDrawableLollipop || who == rippleDrawable || super.verifyDrawable(who);
}
// endregion
// region Getters & Setters
/**
* Set the background radius of the corners of the parent button group in order to round edges
*
* If isLeftButton() is true, this radius will be used to clip the bottom-left and top-left corners.
* If isRightButton() is true, this radius will be used to clip the bottom-right and top-right corners.
* If both are true, then all corners will be rounded with the radius.
* If none are set, no corners are rounded and this parameter is not used.
*
* Note: You must manually call setupBackgroundClipPath after all changes to background radius, left button,
* right button, rounded & width/height are completed.
*
* @param backgroundRadius radius of corners of parent button group in pixels
*/
void setBackgroundRadius(int backgroundRadius)
{
this.backgroundRadius = backgroundRadius;
}
/**
* Returns whether this button is the left-most button in the group
*
* This is determined based on whether the rightButton variable is null
*/
public boolean isLeftButton()
{
return leftButton == null;
}
/**
* Returns whether this button is the right-most button in the group
*
* This is determined based on whether the rightButton variable is null
*/
public boolean isRightButton()
{
return rightButton == null;
}
/**
* Sets the button directly to the left of this button. Set to null to indicate that this is the left-most button
* in the group
*
* Note: You must manually call setupBackgroundClipPath after all changes to background radius,
* leftButton, rightButton, rounded & width/height are completed.
*/
@SuppressWarnings("SameParameterValue")
void setLeftButton(SegmentedButton leftButton)
{
this.leftButton = leftButton;