Skip to content

Commit 9430a73

Browse files
authored
Merge pull request #829 from tobioyelekan/stroke_balloon
Stroke balloon
2 parents fd3e716 + 5095c90 commit 9430a73

File tree

4 files changed

+486
-26
lines changed

4 files changed

+486
-26
lines changed

balloon/api/balloon.api

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ public final class com/skydoves/balloon/Balloon$Builder {
253253
public final fun getBalloonOverlayAnimation ()Lcom/skydoves/balloon/overlay/BalloonOverlayAnimation;
254254
public final fun getBalloonOverlayAnimationStyle ()I
255255
public final fun getBalloonRotateAnimation ()Lcom/skydoves/balloon/animations/BalloonRotateAnimation;
256+
public final fun getBalloonStroke ()Lcom/skydoves/balloon/BalloonStroke;
256257
public final fun getCircularDuration ()J
257258
public final fun getCornerRadius ()F
258259
public final fun getDismissWhenClicked ()Z
@@ -325,6 +326,7 @@ public final class com/skydoves/balloon/Balloon$Builder {
325326
public final fun getWidth ()I
326327
public final fun getWidthRatio ()F
327328
public final fun isAttachedInDecor ()Z
329+
public final fun isClipArrowEnabled ()Z
328330
public final fun isComposableContent ()Z
329331
public final fun isFocusable ()Z
330332
public final fun isRtlLayout ()Z
@@ -402,8 +404,11 @@ public final class com/skydoves/balloon/Balloon$Builder {
402404
public final synthetic fun setBalloonOverlayAnimationStyle (I)V
403405
public final synthetic fun setBalloonRotateAnimation (Lcom/skydoves/balloon/animations/BalloonRotateAnimation;)V
404406
public final fun setBalloonRotationAnimation (Lcom/skydoves/balloon/animations/BalloonRotateAnimation;)Lcom/skydoves/balloon/Balloon$Builder;
407+
public final fun setBalloonStroke (IF)Lcom/skydoves/balloon/Balloon$Builder;
408+
public final synthetic fun setBalloonStroke (Lcom/skydoves/balloon/BalloonStroke;)V
405409
public final fun setCircularDuration (J)Lcom/skydoves/balloon/Balloon$Builder;
406410
public final synthetic fun setCircularDuration (J)V
411+
public final synthetic fun setClipArrowEnabled (Z)V
407412
public final synthetic fun setComposableContent (Z)V
408413
public final fun setCornerRadius (F)Lcom/skydoves/balloon/Balloon$Builder;
409414
public final synthetic fun setCornerRadius (F)V
@@ -457,6 +462,7 @@ public final class com/skydoves/balloon/Balloon$Builder {
457462
public final fun setIncludeFontPadding (Z)Lcom/skydoves/balloon/Balloon$Builder;
458463
public final synthetic fun setIncludeFontPadding (Z)V
459464
public final fun setIsAttachedInDecor (Z)Lcom/skydoves/balloon/Balloon$Builder;
465+
public final fun setIsClipArrowEnabled (Z)Lcom/skydoves/balloon/Balloon$Builder;
460466
public final fun setIsComposableContent (Z)Lcom/skydoves/balloon/Balloon$Builder;
461467
public final fun setIsStatusBarVisible (Z)Lcom/skydoves/balloon/Balloon$Builder;
462468
public final fun setIsVisibleArrow (Z)Lcom/skydoves/balloon/Balloon$Builder;
@@ -785,6 +791,25 @@ public final class com/skydoves/balloon/BalloonSizeSpec {
785791
public static final field WRAP I
786792
}
787793

794+
public final class com/skydoves/balloon/BalloonStroke {
795+
public static final field Companion Lcom/skydoves/balloon/BalloonStroke$Companion;
796+
public static final field STROKE_THICKNESS_MULTIPLIER F
797+
public fun <init> (IF)V
798+
public synthetic fun <init> (IFILkotlin/jvm/internal/DefaultConstructorMarker;)V
799+
public final fun component1 ()I
800+
public final fun component2 ()F
801+
public final fun copy (IF)Lcom/skydoves/balloon/BalloonStroke;
802+
public static synthetic fun copy$default (Lcom/skydoves/balloon/BalloonStroke;IFILjava/lang/Object;)Lcom/skydoves/balloon/BalloonStroke;
803+
public fun equals (Ljava/lang/Object;)Z
804+
public final fun getColor ()I
805+
public final fun getThickness ()F
806+
public fun hashCode ()I
807+
public fun toString ()Ljava/lang/String;
808+
}
809+
810+
public final class com/skydoves/balloon/BalloonStroke$Companion {
811+
}
812+
788813
public final class com/skydoves/balloon/DeferredBalloon {
789814
public fun <init> (Lcom/skydoves/balloon/Balloon;Lcom/skydoves/balloon/BalloonPlacement;)V
790815
public final fun component1 ()Lcom/skydoves/balloon/Balloon;
@@ -1068,8 +1093,25 @@ public final class com/skydoves/balloon/radius/RadiusLayout : android/widget/Fra
10681093
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;)V
10691094
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;I)V
10701095
public synthetic fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
1096+
public final fun getArrowHeight ()F
1097+
public final fun getArrowOrientation ()Lcom/skydoves/balloon/ArrowOrientation;
1098+
public final fun getArrowPositionRatio ()F
1099+
public final fun getArrowWidth ()F
1100+
public final fun getCustomShapeBackgroundDrawable ()Landroid/graphics/drawable/Drawable;
1101+
public final fun getDrawCustomShape ()Z
10711102
public final fun getRadius ()F
1103+
public final fun rebuildPath ()V
1104+
public final fun setArrowHeight (F)V
1105+
public final fun setArrowOrientation (Lcom/skydoves/balloon/ArrowOrientation;)V
1106+
public final fun setArrowPositionRatio (F)V
1107+
public final fun setArrowWidth (F)V
1108+
public final fun setCustomShapeBackgroundDrawable (Landroid/graphics/drawable/Drawable;)V
1109+
public final fun setDrawCustomShape (Z)V
1110+
public final fun setFillColor (I)V
1111+
public fun setPadding (IIII)V
10721112
public final fun setRadius (F)V
1113+
public final fun setStroke (FI)V
1114+
public final fun updateEffectivePadding ()V
10731115
}
10741116

10751117
public final class com/skydoves/balloon/vectortext/VectorTextView : androidx/appcompat/widget/AppCompatTextView {

balloon/src/main/kotlin/com/skydoves/balloon/Balloon.kt

Lines changed: 160 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ import com.skydoves.balloon.internals.unaryMinus
113113
import com.skydoves.balloon.overlay.BalloonOverlayAnimation
114114
import com.skydoves.balloon.overlay.BalloonOverlayOval
115115
import com.skydoves.balloon.overlay.BalloonOverlayShape
116+
import com.skydoves.balloon.radius.RadiusLayout
116117
import kotlinx.coroutines.CoroutineScope
117118
import kotlinx.coroutines.Deferred
118119
import kotlinx.coroutines.Dispatchers
@@ -321,6 +322,66 @@ public class Balloon private constructor(
321322
}
322323
}
323324

325+
/**
326+
* Updates the arrow position on the RadiusLayout based on the anchor position.
327+
* This method calculates the appropriate arrow position ratio and updates the
328+
* RadiusLayout properties when isClipArrowEnabled is true.
329+
*
330+
* @param anchor The anchor view to align the arrow with
331+
*/
332+
private fun updateBalloonCardArrowPosition(anchor: View) {
333+
val balloonCard = binding.balloonCard
334+
val balloonLocation = IntArray(2)
335+
val anchorLocation = IntArray(2)
336+
337+
balloonCard.getLocationOnScreen(balloonLocation)
338+
anchor.getLocationOnScreen(anchorLocation)
339+
340+
val orientation = builder.arrowOrientation.getRTLSupportOrientation(builder.isRtlLayout)
341+
342+
val ratio = when (orientation) {
343+
ArrowOrientation.TOP, ArrowOrientation.BOTTOM -> {
344+
when (builder.arrowPositionRules) {
345+
ArrowPositionRules.ALIGN_ANCHOR -> {
346+
// Use arrowPosition to determine position within anchor
347+
val anchorPositionX = anchorLocation[0] + (anchor.width * builder.arrowPosition)
348+
val balloonLeft = balloonLocation[0].toFloat()
349+
val relativeX = anchorPositionX - balloonLeft
350+
(relativeX / balloonCard.width.toFloat()).coerceIn(0f, 1f)
351+
}
352+
353+
ArrowPositionRules.ALIGN_BALLOON -> {
354+
builder.arrowPosition.coerceIn(0f, 1f)
355+
}
356+
}
357+
}
358+
359+
ArrowOrientation.START, ArrowOrientation.END -> {
360+
when (builder.arrowPositionRules) {
361+
ArrowPositionRules.ALIGN_ANCHOR -> {
362+
// Use arrowPosition to determine position within anchor
363+
val anchorPositionY = anchorLocation[1] + (anchor.height * builder.arrowPosition)
364+
val balloonTop = balloonLocation[1].toFloat()
365+
val relativeY = anchorPositionY - balloonTop
366+
(relativeY / balloonCard.height.toFloat()).coerceIn(0f, 1f)
367+
}
368+
369+
ArrowPositionRules.ALIGN_BALLOON -> {
370+
builder.arrowPosition.coerceIn(0f, 1f)
371+
}
372+
}
373+
}
374+
}
375+
376+
(balloonCard as? RadiusLayout)?.let { layout ->
377+
layout.arrowPositionRatio = ratio
378+
layout.arrowOrientation = orientation
379+
380+
layout.rebuildPath()
381+
layout.updateEffectivePadding()
382+
}
383+
}
384+
324385
/** Returns [BitmapDrawable] that will be used for the foreground of the arrow. */
325386
private fun ImageView.getArrowForeground(x: Float, y: Float): BitmapDrawable? {
326387
return if (builder.arrowColorMatchBalloon && isAPILevelHigherThan23()) {
@@ -544,11 +605,51 @@ public class Balloon private constructor(
544605
with(binding.balloonCard) {
545606
alpha = builder.alpha
546607
radius = builder.cornerRadius
547-
ViewCompat.setElevation(this, builder.elevation)
548-
background = builder.backgroundDrawable ?: GradientDrawable().apply {
549-
setColor(builder.backgroundColor)
550-
cornerRadius = builder.cornerRadius
608+
// Radius and elevation are applied to the RadiusLayout directly
609+
(this as? RadiusLayout)?.let { layout ->
610+
layout.radius = builder.cornerRadius // Radius is still set, used by path creation
611+
ViewCompat.setElevation(layout, builder.elevation)
612+
613+
// --- Determine the drawing mode for RadiusLayout ---
614+
// If true, RadiusLayout handles all drawing
615+
layout.drawCustomShape = builder.isClipArrowEnabled
616+
617+
if (builder.isClipArrowEnabled) {
618+
// If isClipArrowEnabled is true, RadiusLayout will draw its own shape
619+
layout.arrowHeight = builder.arrowSize.toFloat() * 2f
620+
layout.arrowWidth = builder.arrowSize.toFloat()
621+
622+
layout.arrowOrientation = builder.arrowOrientation
623+
624+
// --- Handle Custom Background Drawable for the combined shape ---
625+
if (builder.backgroundDrawable != null) {
626+
layout.customShapeBackgroundDrawable = builder.backgroundDrawable
627+
layout.setFillColor(Color.TRANSPARENT)
628+
} else {
629+
layout.customShapeBackgroundDrawable = null
630+
layout.setFillColor(builder.backgroundColor)
631+
}
632+
633+
// --- ALWAYS configure RadiusLayout's strokePaint here if a stroke is desired ---
634+
// This ensures the stroke is drawn by RadiusLayout on the custom path,
635+
// regardless of whether a custom backgroundDrawable is provided for the fill.
636+
builder.balloonStroke?.let { stroke ->
637+
layout.setStroke(thickness = stroke.thickness, color = stroke.color)
638+
} ?: run {
639+
// No stroke if not provided in builder
640+
layout.setStroke(thickness = 0f, color = Color.TRANSPARENT)
641+
}
642+
} else {
643+
// --- Old mode: ImageView arrow, RadiusLayout acts as a regular FrameLayout with
644+
// rounded background ---
645+
background = builder.backgroundDrawable ?: GradientDrawable().apply {
646+
setColor(builder.backgroundColor)
647+
cornerRadius = builder.cornerRadius
648+
}
649+
}
551650
}
651+
// Apply padding. RadiusLayout's setPadding will now internally adjust for the arrow
652+
// if drawCustomShape is true. Otherwise, it just passes padding to super.
552653
setPadding(
553654
builder.paddingLeft,
554655
builder.paddingTop,
@@ -827,7 +928,19 @@ public class Balloon private constructor(
827928
FrameLayout.LayoutParams.MATCH_PARENT,
828929
FrameLayout.LayoutParams.MATCH_PARENT,
829930
)
830-
initializeArrow(mainAnchor)
931+
932+
if (!builder.isClipArrowEnabled) {
933+
initializeArrow(mainAnchor)
934+
} else {
935+
binding.balloonCard.post {
936+
onBalloonInitializedListener?.onBalloonInitialized(getContentView())
937+
938+
adjustArrowOrientationByRules(mainAnchor)
939+
940+
updateBalloonCardArrowPosition(mainAnchor)
941+
}
942+
}
943+
831944
initializeBalloonContent()
832945

833946
applyBalloonOverlayAnimation()
@@ -861,19 +974,25 @@ public class Balloon private constructor(
861974
val xOff = placement.xOff
862975
val yOff = placement.yOff
863976

977+
val protrusion = if (builder.isClipArrowEnabled) builder.arrowSize else 0
978+
864979
return when (placement.align) {
865980
BalloonAlign.TOP ->
866981
builder.supportRtlLayoutFactor * (halfAnchorWidth - halfBalloonWidth + xOff) to
867-
-(getMeasuredHeight() + anchor.measuredHeight) + yOff
982+
-(getMeasuredHeight() + anchor.measuredHeight - protrusion) + yOff
868983

869984
BalloonAlign.BOTTOM ->
870985
builder.supportRtlLayoutFactor * (halfAnchorWidth - halfBalloonWidth + xOff) to yOff
871986

872-
BalloonAlign.START -> builder.supportRtlLayoutFactor * (-getMeasuredWidth() + xOff) to
873-
-(halfBalloonHeight + halfAnchorHeight) + yOff
987+
BalloonAlign.START -> builder.supportRtlLayoutFactor * (
988+
-getMeasuredWidth() +
989+
protrusion + xOff
990+
) to -(halfBalloonHeight + halfAnchorHeight) + yOff
874991

875-
BalloonAlign.END -> builder.supportRtlLayoutFactor * (anchor.measuredWidth + xOff) to
876-
-(halfBalloonHeight + halfAnchorHeight) + yOff
992+
BalloonAlign.END -> builder.supportRtlLayoutFactor * (
993+
anchor.measuredWidth -
994+
protrusion + xOff
995+
) to -(halfBalloonHeight + halfAnchorHeight) + yOff
877996
}
878997
}
879998

@@ -1629,7 +1748,11 @@ public class Balloon private constructor(
16291748
@MainThread
16301749
private fun update(placement: BalloonPlacement) {
16311750
if (isShowing) {
1632-
updateArrow(placement.anchor)
1751+
if (builder.isClipArrowEnabled) {
1752+
updateBalloonCardArrowPosition(placement.anchor)
1753+
} else {
1754+
updateArrow(placement.anchor)
1755+
}
16331756

16341757
val (xOff, yOff) = calculateOffset(placement)
16351758
this.bodyWindow.update(
@@ -2373,6 +2496,32 @@ public class Balloon private constructor(
23732496
@set:JvmSynthetic
23742497
public var isComposableContent: Boolean = false
23752498

2499+
@set:JvmSynthetic
2500+
public var isClipArrowEnabled: Boolean = false
2501+
2502+
@set:JvmSynthetic
2503+
public var balloonStroke: BalloonStroke? = null
2504+
2505+
/**
2506+
* Sets whether the arrow should be drawn as part of the balloon background shape.
2507+
* When enabled, the arrow is integrated into the balloon's RadiusLayout background
2508+
* and supports stroke drawing. When disabled, uses the legacy separate arrow ImageView.
2509+
* Default is false for backward compatibility.
2510+
*/
2511+
public fun setIsClipArrowEnabled(value: Boolean): Builder = apply {
2512+
this.isClipArrowEnabled = value
2513+
}
2514+
2515+
/**
2516+
* Sets the stroke (outline) properties for the balloon.
2517+
* Only works when isClipArrowEnabled is true.
2518+
* @param color The color of the stroke
2519+
* @param thickness The thickness of the stroke in dp
2520+
**/
2521+
public fun setBalloonStroke(@ColorInt color: Int, @Dp thickness: Float): Builder = apply {
2522+
this.balloonStroke = BalloonStroke(color, thickness)
2523+
}
2524+
23762525
/** sets the width size. */
23772526
public fun setWidth(@Dp value: Int): Builder = apply {
23782527
require(
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright (C) 2019 skydoves
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.skydoves.balloon
18+
19+
import androidx.annotation.ColorInt
20+
import com.skydoves.balloon.annotations.Dp
21+
22+
public data class BalloonStroke(
23+
@ColorInt public val color: Int,
24+
@Dp public val thickness: Float = 1f,
25+
) {
26+
public companion object {
27+
/**
28+
* The stroke looks visually thinner due to how it's drawn over the path,
29+
* so we multiply thickness to compensate for better visibility.
30+
* this is a workaround for the issue.
31+
**/
32+
public const val STROKE_THICKNESS_MULTIPLIER: Float = 1.5f
33+
}
34+
}

0 commit comments

Comments
 (0)