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.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; // 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); } 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); // 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).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).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; } /** * Sets the button directly to the right of this button. Set to null to indicate that this is the right-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 setRightButton(SegmentedButton rightButton) { this.rightButton = rightButton; } /** * Returns whether this button is rounded */ public boolean isRounded() { return rounded; } /** * Sets whether button is rounded regardless of its position in group * * Note: You must manually call setupBackgroundClipPath after all changes to background radius, leftButton, * rightButton, rounded & width/height are completed. */ public void setRounded(boolean rounded) { this.rounded = rounded; } /** * Set the radius of the selected button * * This will round out the selected button or the part shown in this button during animation based on this radius * value. * * @param selectedButtonRadius radius of corners for selected button in pixels */ void setSelectedButtonRadius(int selectedButtonRadius) { this.selectedButtonRadius = selectedButtonRadius; } /** * Setup the background clip path in order to round the edges of this button * * 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 or isRounded() is true, then all corners will be rounded with the radius. * If none are set, no corners are rounded and this parameter is not used. * * This function should be called when the size of the button changes, if the background radius changes and/or if * the isLeftButton() or isRightButton() boolean values change. * * Note that this function internally calls setupBackgroundBitmaps() because a change in the clip path will * require updating the bitmaps */ void setupBackgroundClipPath() { // If there is no background radius then skip if (backgroundRadius == 0) { backgroundClipPath = null; // Update background bitmaps setupBackgroundBitmaps(); return; } // Set rectangle to take up entire view, used to create clip path rectF.set(0, 0, getWidth(), getHeight()); // Background radius, shorthand variable to make code cleaner final float br = backgroundRadius; if (isRounded() || (isLeftButton() && isRightButton())) { // Add radius on all sides, left & right backgroundClipPath = new Path(); backgroundClipPath.addRoundRect(rectF, new float[] {br, br, br, br, br, br, br, br}, Direction.CW); } else if (isLeftButton()) { // Add radius on left side only backgroundClipPath = new Path(); backgroundClipPath.addRoundRect(rectF, new float[] {br, br, 0, 0, 0, 0, br, br}, Direction.CW); } else if (isRightButton()) { // Add radius on right side only backgroundClipPath = new Path(); backgroundClipPath.addRoundRect(rectF, new float[] {0, 0, br, br, br, br, 0, 0}, Direction.CW); } else { backgroundClipPath = null; } // Canvas.clipPath, used in onDraw for drawing the background clip path (rounding the edges for left-most and // right-most buttons) is not supported with hardware acceleration until API 18 // Thus, switch to software acceleration if the background clip path is not null (meaning the edges are // rounded) and the current version is less than 18 if (backgroundClipPath != null && Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) setLayerType(LAYER_TYPE_SOFTWARE, null); // Update background bitmaps setupBackgroundBitmaps(); } /** * Setup the background paint objects so that the background & selected background drawable can be rendered with * rounded corners using a bitmap shader * * This function is called by setupBackgroundClipPath since the background clip determines whether the button has * rounded corners. In addition, this function should be called when the drawable or selected drawable changes, * the selected button radius changes, or the size of either drawable changes. */ void setupBackgroundBitmaps() { Bitmap bitmap; // Setup background paint object to render background using a bitmap shader approach under three conditions: // 1. Background has rounded corners // 2. There is a background drawable // 3. Able to successfully create bitmap from drawable if (backgroundClipPath != null && backgroundDrawable != null && (bitmap = getBitmapFromDrawable(backgroundDrawable)) != null) { final BitmapShader backgroundBitmapShader = new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP); backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); backgroundPaint.setShader(backgroundBitmapShader); } else backgroundPaint = null; // Setup selected background paint object to render background using a bitmap shader approach under three // conditions: // 1. Background has rounded corners OR selected button has rounded corners // 2. There is a background drawable // 3. Able to successfully create bitmap from drawable if ((backgroundClipPath != null || selectedButtonRadius > 0) && selectedBackgroundDrawable != null && (bitmap = getBitmapFromDrawable(selectedBackgroundDrawable)) != null) { final BitmapShader selectedBackgroundBitmapShader = new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP); selectedBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); selectedBackgroundPaint.setShader(selectedBackgroundBitmapShader); } else selectedBackgroundPaint = null; } /** * Setup the selected button clip path to round the corners of the selected button * * This function should be called if the selected button radius is changed */ void setupSelectedButtonClipPath() { // Setup selected button radii // Object allocated here rather than in onDraw to increase performance selectedButtonRadii = new float[] { selectedButtonRadius, selectedButtonRadius, selectedButtonRadius, selectedButtonRadius, selectedButtonRadius, selectedButtonRadius, selectedButtonRadius, selectedButtonRadius }; if (selectedButtonRadius > 0) { // Canvas.clipPath, used in onDraw for drawing the selected button clip path is not supported with // hardware acceleration until API 18. Thus, this switches to software acceleration if current Android // API version is less than 18. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) { setLayerType(LAYER_TYPE_SOFTWARE, null); } } // Update background bitmaps setupBackgroundBitmaps(); invalidate(); } /** * Set the border for the selected button * * @param width Width of the border in pixels (default value is 0px or no border) * @param color Color of the border (default color is black) * @param dashWidth Width of the dash for border, in pixels. Value of 0px means solid line (default is 0px) * @param dashGap Width of the gap for border, in pixels. */ void setSelectedButtonBorder(int width, @ColorInt int color, int dashWidth, int dashGap) { if (width > 0) { // Allocate Paint object for drawing border here // Used in onDraw to draw the border around the selected button selectedButtonBorderPaint = new Paint(Paint.ANTI_ALIAS_FLAG); selectedButtonBorderPaint.setStyle(Paint.Style.STROKE); selectedButtonBorderPaint.setStrokeWidth(width); selectedButtonBorderPaint.setColor(color); if (dashWidth > 0.0f) { selectedButtonBorderPaint.setPathEffect(new DashPathEffect(new float[] {dashWidth, dashGap}, 0)); } } else { // If the width is 0, then disable drawing border selectedButtonBorderPaint = null; } invalidate(); } @Override public void setVisibility(final int visibility) { super.setVisibility(visibility); // Notify the parent button group of change if (onVisibilityChangedListener != null) { onVisibilityChangedListener.onVisibilityChanged(this, visibility); } } /** * Sets the background of the button to be the drawable background if it does not have a background already and * the drawable is not null * * If either one of those conditions are met, then the background is not changed * * This is a package-private function used by SegmentedButtonGroup to pass its 'global' background down to the * buttons * * @param drawable Drawable to set as the background */ void setDefaultBackground(@Nullable Drawable drawable) { if (backgroundDrawable == null && drawable != null) { // Make sure to clone the drawable so that we can set the bounds on it backgroundDrawable = drawable.getConstantState().newDrawable(); } } /** * Sets the selected background of the button to be the drawable background if it does not have a background * already and the drawable is not null * * If either one of those conditions are met, then the background is not changed * * This is a package-private function used by SegmentedButtonGroup to pass its 'global' background down to the * buttons * * @param drawable Drawable to set as the background */ void setDefaultSelectedBackground(@Nullable Drawable drawable) { if (selectedBackgroundDrawable == null && drawable != null) { // Make sure to clone the drawable so that we can set the bounds on it selectedBackgroundDrawable = drawable.getConstantState().newDrawable(); } } /** * Returns the background drawable that is shown when the button is not selected * * In the case a solid color background is used, this will be a ColorDrawable * * @return the current background drawable when the button is not selected */ public Drawable getBackground() { return backgroundDrawable; } /** * Set the background displayed when not selected to a given drawable * * @param drawable drawable to set the background to */ @Override public void setBackground(final Drawable drawable) { backgroundDrawable = drawable; backgroundDrawable.setBounds(0, 0, getWidth(), getHeight()); // Setup the background bitmaps again since background drawable has changed setupBackgroundBitmaps(); invalidate(); } /** * Set the background displayed when not selected to a given color * * This will create a ColorDrawable or modify the current background if it is a ColorDrawable * * @param color color to set the background to */ public void setBackground(@ColorInt int color) { if (backgroundDrawable instanceof ColorDrawable) { // If the current drawable is a ColorDrawable, just change the color ((ColorDrawable)backgroundDrawable.mutate()).setColor(color); } else { backgroundDrawable = new ColorDrawable(color); backgroundDrawable.setBounds(0, 0, getWidth(), getHeight()); } // Setup the background bitmaps again since background drawable has changed setupBackgroundBitmaps(); invalidate(); } /** * Convenience function for setting the background color when not selected * * 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 */ @Override public void setBackgroundColor(@ColorInt int color) { setBackground(color); } /** * Returns the background drawable that is shown when the button is selected * * In the case a solid color background is used, this will be a ColorDrawable * * @return the current background drawable when the button is selected */ public Drawable getSelectedBackground() { return selectedBackgroundDrawable; } /** * Set the background displayed when selected to a given drawable * * @param drawable drawable to set the background to */ public void setSelectedBackground(final Drawable drawable) { selectedBackgroundDrawable = drawable; selectedBackgroundDrawable.setBounds(0, 0, getWidth(), getHeight()); // Setup the background bitmaps again since background drawable has changed setupBackgroundBitmaps(); invalidate(); } /** * Set the background displayed when selected to a given color * * This will create a ColorDrawable or modify the current background if it is a ColorDrawable * * @param color color to set the background to */ public void setSelectedBackground(@ColorInt int color) { if (selectedBackgroundDrawable instanceof ColorDrawable) { // If the current drawable is a ColorDrawable, just change the color ((ColorDrawable)selectedBackgroundDrawable.mutate()).setColor(color); } else { selectedBackgroundDrawable = new ColorDrawable(color); selectedBackgroundDrawable.setBounds(0, 0, getWidth(), getHeight()); } // Setup the background bitmaps again since background drawable has changed setupBackgroundBitmaps(); invalidate(); } /** * Convenience function for setting the background color when selected * * @param color color to set the background to */ public void setSelectedBackgroundColor(@ColorInt int color) { setSelectedBackground(color); } /** * Returns the ripple color used for displaying the ripple effect on button press * * The ripple color is a tint color applied on top of the button when it is pressed */ public int getRippleColor() { return rippleColor; } /** * Set ripple effect to be either enabled or disabled on button press * * If enabled, then the ripple color used will be the last ripple color set for this button or the default value * of gray * * Note: This function is package-private because enabling or disabling the ripple effect on a per-button basis * sounds like a terrible idea * * @param enabled whether or not to enable the ripple effect for this button */ void setRipple(boolean enabled) { if (enabled) { // Recreate the ripple drawable and setup with the ripple color setRipple(rippleColor); } else { // Set both ripple drawables to null so that we do not draw the ripple rippleDrawableLollipop = null; rippleDrawable = null; } } /** * Set ripple color used for ripple effect on button press * * @param color color to set for the ripple effect for this button */ public void setRipple(@ColorInt int color) { rippleColor = color; if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { rippleDrawableLollipop = new RippleDrawable(ColorStateList.valueOf(rippleColor), null, null); // setCallback on Drawable allows animations to be scheduled and the drawable to invalidate the view on // animation rippleDrawableLollipop.setCallback(this); rippleDrawableLollipop.setBounds(0, 0, getWidth(), getHeight()); // Disable/nullify the pre-lollipop RippleDrawable backport rippleDrawable = null; } else { rippleDrawable = new codetail.graphics.drawables.RippleDrawable(ColorStateList.valueOf(rippleColor), null, null); // setCallback on Drawable allows animations to be scheduled and the drawable to invalidate the view on // animation rippleDrawable.setCallback(this); rippleDrawable.setBounds(0, 0, getWidth(), getHeight()); setOnTouchListener(new DrawableHotspotTouch(rippleDrawable)); // Disable/nullify the lollipop RippleDrawable rippleDrawableLollipop = null; } invalidate(); } /** * Returns the drawable for the button or null if no drawable is set */ public Drawable getDrawable() { return drawable; } /** * Set the drawable for the button * * If drawable is null, then the drawable is removed from the button * * @param drawable Drawable to set for the button */ public void setDrawable(final @Nullable Drawable drawable) { this.drawable = drawable; // Request a layout, drawable may have different size and things need to be rearranged requestLayout(); // Calculate new positions and bounds for text & drawable // This may be redundant if the case that onSizeChanged gets called but there are cases where the size doesnt // change but the positions still need to be recalculated updateSize(); } /** * Returns the drawable padding * * The drawable padding is the space between the drawable and text, in pixels. If the drawable or the text is not * present, then the padding is ignored. */ public int getDrawablePadding() { return drawablePadding; } /** * Set the drawable padding * * The drawable padding is the space between the drawable and text, in pixels. If the drawable or the text is not * present, then the padding is ignored. * * @param padding padding in pixels to set for the drawable */ public void setDrawablePadding(final int padding) { drawablePadding = padding; requestLayout(); // Calculate new positions and bounds for text & drawable // This may be redundant if the case that onSizeChanged gets called but there are cases where the size doesnt // change but the positions still need to be recalculated updateSize(); } /** * Whether or not there is a tint color applied to the drawable when the button is not selected */ public boolean hasDrawableTint() { return hasDrawableTint; } /** * Tint color applied to the drawable when the button is not selected * * If hasDrawableTint is false, then this value will be undefined */ public int getDrawableTint() { return drawableTint; } /** * Set the drawable tint color to the specified color * * This drawable tint color will be the color applied to the drawable when the button is not selected * * @param tint color for the drawable tint */ public void setDrawableTint(final @ColorInt int tint) { hasDrawableTint = true; drawableTint = tint; // Create color filter for the tint color drawableColorFilter = new PorterDuffColorFilter(tint, PorterDuff.Mode.SRC_IN); invalidate(); } /** * Remove drawable tint color so that the normal drawable is shown when the button is not selected */ public void removeDrawableTint() { hasDrawableTint = false; drawableColorFilter = null; invalidate(); } /** * Whether or not there is a tint color applied to the drawable when the button is selected */ public boolean hasSelectedDrawableTint() { return hasSelectedDrawableTint; } /** * Tint color applied to the drawable when the button is selected * * If hasSelectedDrawableTint is false, then this value will be undefined */ public int getSelectedDrawableTint() { return selectedDrawableTint; } /** * Set the drawable tint color to the specified color * * This drawable tint color will be the color applied to the drawable when the button is selected * * @param tint color for the drawable tint */ public void setSelectedDrawableTint(final @ColorInt int tint) { hasSelectedDrawableTint = true; selectedDrawableTint = tint; // Create color filter for the tint color selectedDrawableColorFilter = new PorterDuffColorFilter(tint, PorterDuff.Mode.SRC_IN); invalidate(); } /** * Remove drawable tint color so that the normal drawable is shown when the button is selected */ public void removeSelectedDrawableTint() { hasSelectedDrawableTint = false; selectedDrawableColorFilter = null; invalidate(); } /** * Whether or not the drawable has a width that was explicitly given */ public boolean hasDrawableWidth() { return hasDrawableWidth; } /** * Returns the drawable width in pixels * * If hasDrawableWidth is false, then this value will be undefined */ public int getDrawableWidth() { return drawableWidth; } /** * Set the drawable width in pixels * * If the width is -1, then the drawable width will be removed and the intrinsic drawable width will be used * instead * * @param width size in pixels of the width of the drawable */ public void setDrawableWidth(final int width) { hasDrawableWidth = (width != -1); drawableWidth = width; // Request relayout because the drawable width is different now requestLayout(); // Calculate new positions and bounds for text & drawable // This may be redundant if the case that onSizeChanged gets called but there are cases where the size doesnt // change but the positions still need to be recalculated updateSize(); } /** * Whether or not the drawable has a height that was explicitly given */ public boolean hasDrawableHeight() { return hasDrawableHeight; } /** * Returns the drawable height in pixels * * If hasDrawableHeight is false, then this value will be undefined */ public int getDrawableHeight() { return drawableHeight; } /** * Set the drawable height in pixels * * If the height is -1, then the drawable height will be removed and the intrinsic drawable height will be used * instead * * @param height size in pixels of the height of the drawable */ public void setDrawableHeight(final int height) { hasDrawableHeight = (height != -1); drawableHeight = height; // Request relayout because the drawable width is different now requestLayout(); // Calculate new positions and bounds for text & drawable // This may be redundant if the case that onSizeChanged gets called but there are cases where the size doesnt // change but the positions still need to be recalculated updateSize(); } /** * Returns the gravity for the drawable * * The drawable can be placed to the left, top, right, or bottom of the text via this parameter */ public int getDrawableGravity() { return drawableGravity; } /** * Set the drawable gravity * * Can be one of the following values: * - Gravity.LEFT * - Gravity.TOP * - Gravity.RIGHT * - Gravity.BOTTOM * * The drawable gravity indicates the location of the drawable in relation to the text. If no text is being * displayed, this property will be ignored. * * @param gravity new drawable gravity */ public void setDrawableGravity(final @GravityOptions int gravity) { drawableGravity = gravity; // Request relayout because the drawable width is different now requestLayout(); // Calculate new positions and bounds for text & drawable // This may be redundant if the case that onSizeChanged gets called but there are cases where the size doesnt // change but the positions still need to be recalculated updateSize(); } /** * Return the text currently being displayed * * If no text is being shown, this will be either null or an empty string */ public String getText() { return text; } /** * Set the text to a new string * * If the string is null or an empty string, then the text will be hidden * * @param text new string to set text to in the button */ public void setText(final @Nullable String text) { this.hasText = (text != null && !text.isEmpty()); this.text = text; initText(); requestLayout(); // Calculate new positions and bounds for text & drawable // This may be redundant in the case that onSizeChanged gets called but there are cases where the size doesnt // change but the positions still need to be recalculated updateSize(); } /** * Returns the text color when the button is not selected */ public int getTextColor() { return textColor; } /** * Set the text color when the button is unselected * * @param color text color */ public void setTextColor(final @ColorInt int color) { textColor = color; invalidate(); } /** * Whether or not there is a text color when this button is selected * * If this is false, then the text color will be the same as when the button is unselected */ public boolean hasSelectedTextColor() { return hasSelectedTextColor; } /** * Returns the text color when the button is selected * * If hasSelectedTextColor is false, then this returned value is undefined */ public int getSelectedTextColor() { return selectedTextColor; } /** * Set the text color when the button is selected * * @param color text color */ public void setSelectedTextColor(final @ColorInt int color) { hasSelectedTextColor = true; selectedTextColor = color; invalidate(); } /** * Remove the text color when the button is selected * * The text color of the button when selected will be the normal text color of the button */ public void removeSelectedTextColor() { hasSelectedTextColor = false; invalidate(); } /** * Return the size of the text in pixels */ public float getTextSize() { return textSize; } /** * Set the size of the text in pixels * * @param size new size in pixels of the text */ public void setTextSize(final float size) { textSize = size; if (!hasText) return; textPaint.setTextSize(size); initText(); requestLayout(); // Calculate new positions and bounds for text & drawable // This may be redundant if the case that onSizeChanged gets called but there are cases where the size doesnt // change but the positions still need to be recalculated updateSize(); } /** * Return the current typeface used for drawing text */ public Typeface getTextTypeface() { return textTypeface; } /** * Set a new typeface to use for drawing text * * @param typeface new typeface for text */ public void setTextTypeface(final Typeface typeface) { textTypeface = typeface; refreshTypeface(); } /** * Return the current selected typeface used for drawing text */ public Typeface getSelectedTextTypeface() { return selectedTextTypeface; } /** * Set a new typeface to use for drawing text when the button is selected * * @param typeface new typeface for selected text */ public void setSelectedTextTypeface(final Typeface typeface) { selectedTextTypeface = typeface; refreshTypeface(); } private void refreshTypeface() { initText(); requestLayout(); // Calculate new positions and bounds for text & drawable // This may be redundant if the case that onSizeChanged gets called but there are cases where the size doesn't // change but the positions still need to be recalculated updateSize(); } /** * This sets a listener that will be called when the visibility of the current button is changed. * * This listener is meant for internal use by SegmentedButtonGroup ONLY * * DO NOT USE */ void _setOnVisibilityChangedListener(OnVisibilityChangedListener listener) { this.onVisibilityChangedListener = listener; } // endregion // region Helper functions /** * Create a bitmap from a specified vector drawable * * @param vectorDrawable vector drawable to convert to a bitmap */ public static Bitmap getBitmapFromVectorDrawable(Drawable vectorDrawable) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { vectorDrawable = (DrawableCompat.wrap(vectorDrawable)).mutate(); } Bitmap bitmap = Bitmap.createBitmap(vectorDrawable.getIntrinsicWidth(), vectorDrawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); Canvas canvas = new Canvas(bitmap); vectorDrawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); vectorDrawable.draw(canvas); return bitmap; } /** * Create a bitmap from a specified drawable * * Note that if the size of the drawable changes, then the bitmap will need to be changed. * * @param drawable drawable to convert to a bitmap */ private static Bitmap getBitmapFromDrawable(@Nullable Drawable drawable) { if (drawable == null) return null; // Return bitmap if drawable is BitmapDrawable if (drawable instanceof BitmapDrawable) return ((BitmapDrawable)drawable).getBitmap(); try { Bitmap bitmap; if (drawable instanceof ColorDrawable) { // Create a bitmap of fixed size for ColorDrawable since it inherently has no size // Ideally, this size can be small because the bitmap can be stretched to fit any width/height // without loss of quality bitmap = Bitmap.createBitmap(COLORDRAWABLE_SIZE, COLORDRAWABLE_SIZE, BITMAP_CONFIG); } else if (drawable instanceof GradientDrawable) { // GradientDrawable ALSO doesn't have a inherent size // However, the size of the bitmap used to represent the GradientDrawable should be the size of the // bounds. // A small fixed size here would result in a pixelated bitmap being drawn // Return null if bounds are 0, this occurs if function is called before button is laid out final Rect bounds = drawable.getBounds(); if (bounds.width() > 0 && bounds.height() > 0) bitmap = Bitmap.createBitmap(bounds.width(), bounds.height(), BITMAP_CONFIG); else return null; } else { // Otherwise, create bitmap based on intrinsic size of the drawable bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), BITMAP_CONFIG); } // Create canvas using bitmap Canvas canvas = new Canvas(bitmap); // Draw the drawable on the canvas // Save the bounds before hand and reset the drawable bounds afterwards final Rect bounds = new Rect(drawable.getBounds()); drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); drawable.draw(canvas); drawable.setBounds(bounds); return bitmap; } catch (Exception e) { // There was an unexpected problem, print out the stack track e.printStackTrace(); return null; } } // endregion // region Listeners /** * Interface definition for a callback that will be invoked when the visibility of the button changes * * This is an internal listener meant to be used ONLY by the SegmentedButtonGroup */ public interface OnVisibilityChangedListener { void onVisibilityChanged(SegmentedButton button, int visibility); } // endregion }