@@ -113,6 +113,7 @@ import com.skydoves.balloon.internals.unaryMinus
113
113
import com.skydoves.balloon.overlay.BalloonOverlayAnimation
114
114
import com.skydoves.balloon.overlay.BalloonOverlayOval
115
115
import com.skydoves.balloon.overlay.BalloonOverlayShape
116
+ import com.skydoves.balloon.radius.RadiusLayout
116
117
import kotlinx.coroutines.CoroutineScope
117
118
import kotlinx.coroutines.Deferred
118
119
import kotlinx.coroutines.Dispatchers
@@ -321,6 +322,66 @@ public class Balloon private constructor(
321
322
}
322
323
}
323
324
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
+
324
385
/* * Returns [BitmapDrawable] that will be used for the foreground of the arrow. */
325
386
private fun ImageView.getArrowForeground (x : Float , y : Float ): BitmapDrawable ? {
326
387
return if (builder.arrowColorMatchBalloon && isAPILevelHigherThan23()) {
@@ -544,11 +605,51 @@ public class Balloon private constructor(
544
605
with (binding.balloonCard) {
545
606
alpha = builder.alpha
546
607
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
+ }
551
650
}
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.
552
653
setPadding(
553
654
builder.paddingLeft,
554
655
builder.paddingTop,
@@ -827,7 +928,19 @@ public class Balloon private constructor(
827
928
FrameLayout .LayoutParams .MATCH_PARENT ,
828
929
FrameLayout .LayoutParams .MATCH_PARENT ,
829
930
)
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
+
831
944
initializeBalloonContent()
832
945
833
946
applyBalloonOverlayAnimation()
@@ -861,19 +974,25 @@ public class Balloon private constructor(
861
974
val xOff = placement.xOff
862
975
val yOff = placement.yOff
863
976
977
+ val protrusion = if (builder.isClipArrowEnabled) builder.arrowSize else 0
978
+
864
979
return when (placement.align) {
865
980
BalloonAlign .TOP ->
866
981
builder.supportRtlLayoutFactor * (halfAnchorWidth - halfBalloonWidth + xOff) to
867
- - (getMeasuredHeight() + anchor.measuredHeight) + yOff
982
+ - (getMeasuredHeight() + anchor.measuredHeight - protrusion ) + yOff
868
983
869
984
BalloonAlign .BOTTOM ->
870
985
builder.supportRtlLayoutFactor * (halfAnchorWidth - halfBalloonWidth + xOff) to yOff
871
986
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
874
991
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
877
996
}
878
997
}
879
998
@@ -1629,7 +1748,11 @@ public class Balloon private constructor(
1629
1748
@MainThread
1630
1749
private fun update (placement : BalloonPlacement ) {
1631
1750
if (isShowing) {
1632
- updateArrow(placement.anchor)
1751
+ if (builder.isClipArrowEnabled) {
1752
+ updateBalloonCardArrowPosition(placement.anchor)
1753
+ } else {
1754
+ updateArrow(placement.anchor)
1755
+ }
1633
1756
1634
1757
val (xOff, yOff) = calculateOffset(placement)
1635
1758
this .bodyWindow.update(
@@ -2373,6 +2496,32 @@ public class Balloon private constructor(
2373
2496
@set:JvmSynthetic
2374
2497
public var isComposableContent: Boolean = false
2375
2498
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
+
2376
2525
/* * sets the width size. */
2377
2526
public fun setWidth (@Dp value : Int ): Builder = apply {
2378
2527
require(
0 commit comments