├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── smarttoolfactory │ │ └── bubblelayout │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── smarttoolfactory │ │ │ └── bubblelayout │ │ │ ├── ArrowPath.kt │ │ │ ├── BubbleLayout.kt │ │ │ ├── BubbleLinearLayout.kt │ │ │ ├── BubbleModifier.kt │ │ │ ├── CurveDrawView.kt │ │ │ ├── LogMeasurementSpecs.kt │ │ │ ├── MainActivity.kt │ │ │ └── Util.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── activity_main_bubble_linear_with_attrs.xml │ │ ├── activity_main_curve.xml │ │ └── activity_main_with_attrs.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── attrs_bubble_layout.xml │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── smarttoolfactory │ └── bubblelayout │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── screenshots ├── img1.png └── img2.png └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/* 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BubbleLayout and ChatRowLayout 2 | 3 | Chat/Speech bubble width different arrow, background, shadow properties to create chat bubbles 4 | like whatsapp, telegram or other messaging apps have or arrows with arrow at bottom to create 5 | info bubble. 6 | 7 | ChatRowLayout is full row for a chat/messaging app that contains bubble as background that 8 | measures and positions bubble based on content's position 9 | 10 | ## Bubble Layout 11 | 12 | 13 | ## ChatRowLayout 14 | 15 | | Default | Debug | 16 | | ----------|----------------| 17 | | | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | } 5 | 6 | android { 7 | compileSdkVersion 30 8 | buildToolsVersion "30.0.3" 9 | 10 | defaultConfig { 11 | applicationId "com.smarttoolfactory.bubblelayout" 12 | minSdkVersion 21 13 | targetSdkVersion 30 14 | versionCode 1 15 | versionName "1.0" 16 | 17 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 18 | } 19 | 20 | buildTypes { 21 | release { 22 | minifyEnabled false 23 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility JavaVersion.VERSION_1_8 28 | targetCompatibility JavaVersion.VERSION_1_8 29 | } 30 | kotlinOptions { 31 | jvmTarget = '1.8' 32 | } 33 | } 34 | 35 | dependencies { 36 | 37 | implementation 'androidx.core:core-ktx:1.3.2' 38 | implementation 'androidx.appcompat:appcompat:1.2.0' 39 | implementation 'com.google.android.material:material:1.3.0' 40 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 41 | testImplementation 'junit:junit:4.+' 42 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 43 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 44 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/smarttoolfactory/bubblelayout/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.smarttoolfactory.bubblelayout 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.smarttoolfactory.bubblelayout", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/smarttoolfactory/bubblelayout/ArrowPath.kt: -------------------------------------------------------------------------------- 1 | package com.smarttoolfactory.bubblelayout 2 | 3 | import android.graphics.Path 4 | import android.graphics.RectF 5 | 6 | /** 7 | * Create path for arrow either aligned left or right side of the bubble 8 | */ 9 | fun createHorizontalArrowPath( 10 | path: Path, 11 | contentRect: RectF, 12 | modifier: BubbleModifier 13 | ) { 14 | val alignment = modifier.arrowAlignment 15 | if (alignment == NONE) return 16 | 17 | val contentHeight = contentRect.height() 18 | val contentLeft = contentRect.left 19 | val contentRight = contentRect.right 20 | val contentTop = contentRect.top 21 | 22 | val arrowWidth = modifier.arrowWidth 23 | 24 | val cornerRadius = modifier.cornerRadiusBundle 25 | 26 | val radiusSumOnArrowSide = when { 27 | isHorizontalLeftAligned(alignment) -> { 28 | cornerRadius.topLeft + cornerRadius.bottomLeft 29 | } 30 | else -> { 31 | cornerRadius.topRight + cornerRadius.bottomRight 32 | } 33 | } 34 | 35 | // Height of the arrow is limited to height of the bubble 36 | // val arrowHeight = 37 | // if (modifier.arrowHeight + radiusSumOnArrowSide > contentHeight) 38 | // contentHeight - radiusSumOnArrowSide else modifier.arrowHeight 39 | 40 | val arrowHeight = modifier.arrowHeight.coerceAtMost(contentHeight) 41 | modifier.arrowHeight = arrowHeight 42 | 43 | // This is offset from top/bottom or center for arrows on left or right. 44 | // Maximum offset + arrow height cannot be bigger 45 | // than bottom of bubble or smaller than top of bubble. 46 | val arrowTop = calculateArrowTopPosition(modifier, arrowHeight, contentTop, contentHeight) 47 | modifier.arrowTop = arrowTop 48 | 49 | val arrowBottom = arrowTop + arrowHeight 50 | modifier.arrowBottom = arrowBottom 51 | 52 | val arrowShape = modifier.arrowShape 53 | 54 | when (alignment) { 55 | 56 | LEFT_TOP -> { 57 | // move to top of arrow at the top of left corner 58 | path.moveTo(contentLeft, arrowTop) 59 | 60 | when (arrowShape) { 61 | 62 | ArrowShape.TRIANGLE_RIGHT -> { 63 | // Draw horizontal line to left 64 | path.lineTo(0f, arrowTop) 65 | path.lineTo(contentLeft, arrowBottom) 66 | } 67 | 68 | ArrowShape.TRIANGLE_ISOSCELES -> { 69 | path.lineTo(0f, arrowTop + arrowHeight / 2f) 70 | path.lineTo(contentLeft, arrowBottom) 71 | } 72 | 73 | ArrowShape.CURVED -> { 74 | 75 | } 76 | } 77 | } 78 | 79 | LEFT_BOTTOM -> { 80 | 81 | // move to top of arrow at the bottom left corner 82 | path.moveTo(contentLeft, arrowTop) 83 | 84 | when (arrowShape) { 85 | 86 | ArrowShape.TRIANGLE_RIGHT -> { 87 | // Draw horizontal line to left 88 | path.lineTo(0f, arrowBottom) 89 | path.lineTo(contentLeft, arrowBottom) 90 | } 91 | 92 | ArrowShape.TRIANGLE_ISOSCELES -> { 93 | // Draw horizontal line to left 94 | path.lineTo(0f, arrowTop + arrowHeight / 2f) 95 | path.lineTo(contentLeft, arrowBottom) 96 | } 97 | 98 | ArrowShape.CURVED -> { 99 | 100 | } 101 | } 102 | } 103 | 104 | LEFT_CENTER -> { 105 | 106 | // move to top of arrow at the top of left corner 107 | path.moveTo(contentLeft, arrowTop) 108 | 109 | when (arrowShape) { 110 | 111 | ArrowShape.TRIANGLE_RIGHT -> { 112 | // Draw horizontal line to left 113 | path.lineTo(0f, arrowTop) 114 | path.lineTo(contentLeft, arrowBottom) 115 | } 116 | 117 | ArrowShape.TRIANGLE_ISOSCELES -> { 118 | path.lineTo(0f, arrowTop + arrowHeight / 2f) 119 | path.lineTo(contentLeft, arrowBottom) 120 | } 121 | 122 | ArrowShape.CURVED -> { 123 | 124 | } 125 | } 126 | } 127 | 128 | RIGHT_TOP -> { 129 | 130 | // move to top right corner of the content 131 | path.moveTo(contentRight, arrowTop) 132 | 133 | when (arrowShape) { 134 | 135 | ArrowShape.TRIANGLE_RIGHT -> { 136 | path.lineTo(contentRight + arrowWidth, arrowTop) 137 | path.lineTo(contentRight, arrowBottom) 138 | } 139 | 140 | ArrowShape.TRIANGLE_ISOSCELES -> { 141 | path.lineTo(contentRight + arrowWidth, arrowTop + arrowHeight / 2f) 142 | path.lineTo(contentRight, arrowBottom) 143 | } 144 | 145 | ArrowShape.CURVED -> { 146 | 147 | } 148 | } 149 | } 150 | 151 | RIGHT_BOTTOM -> { 152 | 153 | // move to bottom right corner of the content 154 | path.moveTo(contentRight, arrowTop) 155 | 156 | when (arrowShape) { 157 | 158 | ArrowShape.TRIANGLE_RIGHT -> { 159 | path.lineTo(contentRight + arrowWidth, arrowBottom) 160 | path.lineTo(contentRight, arrowBottom) 161 | } 162 | 163 | ArrowShape.TRIANGLE_ISOSCELES -> { 164 | path.lineTo(contentRight + arrowWidth, arrowTop + arrowHeight / 2f) 165 | path.lineTo(contentRight, arrowBottom) 166 | } 167 | 168 | ArrowShape.CURVED -> { 169 | 170 | } 171 | } 172 | } 173 | 174 | RIGHT_CENTER -> { 175 | 176 | // move to top right corner of the content 177 | path.moveTo(contentRight, arrowTop) 178 | 179 | when (arrowShape) { 180 | 181 | ArrowShape.TRIANGLE_RIGHT -> { 182 | path.lineTo(contentRight + arrowWidth, arrowTop) 183 | path.lineTo(contentRight, arrowBottom) 184 | } 185 | 186 | ArrowShape.TRIANGLE_ISOSCELES -> { 187 | path.lineTo(contentRight + arrowWidth, arrowTop + arrowHeight / 2f) 188 | path.lineTo(contentRight, arrowBottom) 189 | } 190 | 191 | ArrowShape.CURVED -> { 192 | 193 | } 194 | } 195 | } 196 | 197 | else -> Unit 198 | } 199 | path.close() 200 | } 201 | 202 | 203 | /** 204 | * Calculate top position of the arrow on either left or right side 205 | */ 206 | private fun calculateArrowTopPosition( 207 | modifier: BubbleModifier, 208 | arrowHeight: Float, 209 | contentTop: Float, 210 | contentHeight: Float 211 | ): Float { 212 | 213 | val alignment = modifier.arrowAlignment 214 | 215 | var arrowTop = when { 216 | isHorizontalTopAligned(alignment) -> { 217 | contentTop + modifier.arrowOffsetY 218 | } 219 | isHorizontalBottomAligned(alignment) -> { 220 | contentHeight + modifier.arrowOffsetY - arrowHeight 221 | } 222 | else -> { 223 | (contentHeight - arrowHeight) / 2f + modifier.arrowOffsetY 224 | } 225 | } 226 | 227 | if (arrowTop < 0) arrowTop = 0f 228 | 229 | if (arrowTop + arrowHeight > contentHeight) arrowTop = contentHeight - arrowHeight 230 | 231 | return arrowTop 232 | } 233 | 234 | /** 235 | * Create path for arrow that is at the bottom of the bubble 236 | */ 237 | fun createVerticalArrowPath(path: Path, contentRect: RectF, modifier: BubbleModifier) { 238 | 239 | val alignment = modifier.arrowAlignment 240 | 241 | if (alignment == NONE) return 242 | 243 | val contentHeight = contentRect.height() 244 | val contentWidth = contentRect.width() 245 | 246 | val contentLeft = contentRect.left 247 | val contentRight = contentRect.right 248 | val contentTop = contentRect.top 249 | val contentBottom = contentRect.bottom 250 | 251 | val cornerRadius = modifier.cornerRadiusBundle 252 | 253 | // TODO This is for bottom arrow, we take only bottom corners to have space to draw arrow 254 | val radiusSumOnArrowSide = cornerRadius.bottomLeft + cornerRadius.bottomRight 255 | 256 | // Width of the arrow is limited to height of the bubble minus sum of corner radius 257 | // of top and bottom in respective side 258 | 259 | val arrowWidth = 260 | if (modifier.arrowWidth + radiusSumOnArrowSide > contentWidth) 261 | contentWidth - radiusSumOnArrowSide else modifier.arrowWidth 262 | 263 | val arrowHeight = modifier.arrowHeight 264 | 265 | val arrowLeft = calculateArrowLeftPosition(modifier, arrowWidth, contentLeft, contentWidth) 266 | val arrowRight = arrowLeft + arrowWidth 267 | val arrowBottom = contentBottom + arrowHeight 268 | 269 | val arrowShape = modifier.arrowShape 270 | 271 | when (alignment) { 272 | BOTTOM_LEFT -> { 273 | path.moveTo(arrowLeft, contentBottom) 274 | 275 | when (arrowShape) { 276 | 277 | ArrowShape.TRIANGLE_RIGHT -> { 278 | path.lineTo(arrowLeft, arrowBottom) 279 | path.lineTo(arrowRight, contentBottom) 280 | } 281 | 282 | ArrowShape.TRIANGLE_ISOSCELES -> { 283 | path.lineTo(arrowLeft + arrowWidth / 2f, arrowBottom) 284 | path.lineTo(arrowRight, contentBottom) 285 | } 286 | 287 | ArrowShape.CURVED -> { 288 | 289 | } 290 | } 291 | 292 | } 293 | 294 | BOTTOM_RIGHT -> { 295 | path.moveTo(arrowLeft, contentBottom) 296 | 297 | when (arrowShape) { 298 | 299 | ArrowShape.TRIANGLE_RIGHT -> { 300 | path.lineTo(arrowRight, arrowBottom) 301 | path.lineTo(arrowRight, contentBottom) 302 | } 303 | 304 | ArrowShape.TRIANGLE_ISOSCELES -> { 305 | path.lineTo(arrowLeft + arrowWidth / 2f, arrowBottom) 306 | path.lineTo(arrowRight, contentBottom) 307 | } 308 | 309 | ArrowShape.CURVED -> { 310 | 311 | } 312 | } 313 | } 314 | 315 | BOTTOM_CENTER -> { 316 | path.moveTo(arrowLeft, contentBottom) 317 | 318 | when (arrowShape) { 319 | 320 | ArrowShape.TRIANGLE_RIGHT -> { 321 | // Draw horizontal line to left 322 | path.lineTo(arrowLeft, arrowBottom) 323 | path.lineTo(arrowRight, contentBottom) 324 | } 325 | 326 | ArrowShape.TRIANGLE_ISOSCELES -> { 327 | path.lineTo(arrowLeft + arrowWidth / 2f, arrowBottom) 328 | path.lineTo(arrowRight, contentBottom) 329 | } 330 | 331 | ArrowShape.CURVED -> { 332 | 333 | } 334 | } 335 | } 336 | 337 | else -> Unit 338 | } 339 | 340 | path.close() 341 | } 342 | 343 | private fun calculateArrowLeftPosition( 344 | modifier: BubbleModifier, 345 | arrowWidth: Float, 346 | contentLeft: Float, 347 | contentWidth: Float 348 | ): Float { 349 | 350 | val alignment = modifier.arrowAlignment 351 | 352 | var arrowLeft = when { 353 | isVerticalLeftAligned(alignment) -> { 354 | contentLeft + modifier.arrowOffsetX 355 | } 356 | isVerticalRightAligned(alignment) -> { 357 | contentWidth + modifier.arrowOffsetX - arrowWidth 358 | } 359 | else -> { 360 | (contentWidth - arrowWidth) / 2f + modifier.arrowOffsetX 361 | } 362 | } 363 | 364 | if (arrowLeft < 0) arrowLeft = 0f 365 | 366 | if (arrowLeft + arrowWidth > contentWidth) arrowLeft = contentWidth - arrowWidth 367 | 368 | return arrowLeft 369 | } 370 | 371 | /** 372 | * Arrow is on left side of the bubble 373 | */ 374 | internal fun isHorizontalLeftAligned(alignment: Int): Boolean { 375 | return (alignment == LEFT_TOP 376 | || alignment == LEFT_BOTTOM 377 | || alignment == LEFT_CENTER) 378 | } 379 | 380 | /** 381 | * Arrow is on right side of the bubble 382 | */ 383 | internal fun isHorizontalRightAligned(alignment: Int): Boolean { 384 | return (alignment == RIGHT_TOP 385 | || alignment == RIGHT_BOTTOM 386 | || alignment == RIGHT_CENTER) 387 | } 388 | 389 | /** 390 | * Arrow is on top left or right side of the bubble 391 | */ 392 | private fun isHorizontalTopAligned(alignment: Int): Boolean { 393 | return (alignment == LEFT_TOP || alignment == RIGHT_TOP) 394 | } 395 | 396 | /** 397 | * Arrow is on top left or right side of the bubble 398 | */ 399 | private fun isHorizontalBottomAligned(alignment: Int): Boolean { 400 | return (alignment == LEFT_BOTTOM || alignment == RIGHT_BOTTOM) 401 | } 402 | 403 | 404 | /** 405 | * Check if arrow is horizontally positioned either on left or right side 406 | */ 407 | internal fun isArrowHorizontalPosition(alignment: Int): Boolean { 408 | return isHorizontalLeftAligned(alignment) 409 | || isHorizontalRightAligned(alignment) 410 | } 411 | 412 | internal fun isVerticalBottomAligned(alignment: Int): Boolean { 413 | return alignment == BOTTOM_LEFT || alignment == BOTTOM_RIGHT || alignment == BOTTOM_CENTER 414 | } 415 | 416 | /** 417 | * Arrow is on left side of the bubble 418 | */ 419 | internal fun isVerticalLeftAligned(alignment: Int): Boolean { 420 | return (alignment == BOTTOM_LEFT) 421 | } 422 | 423 | /** 424 | * Arrow is on right side of the bubble 425 | */ 426 | internal fun isVerticalRightAligned(alignment: Int): Boolean { 427 | return (alignment == BOTTOM_RIGHT) 428 | } 429 | 430 | /** 431 | * Check if arrow is vertically positioned either on top or at the bottom of bubble 432 | */ 433 | internal fun isArrowVerticalPosition(alignment: Int): Boolean { 434 | return isVerticalBottomAligned(alignment) 435 | } 436 | -------------------------------------------------------------------------------- /app/src/main/java/com/smarttoolfactory/bubblelayout/BubbleLayout.kt: -------------------------------------------------------------------------------- 1 | package com.smarttoolfactory.bubblelayout 2 | 3 | import android.content.Context 4 | import android.graphics.* 5 | import android.os.Build 6 | import android.util.AttributeSet 7 | import android.view.View 8 | import android.view.ViewOutlineProvider 9 | import android.widget.FrameLayout 10 | import kotlin.math.min 11 | 12 | /** 13 | * Linear layout that draws chat or speech bubble with specified properties. 14 | * 15 | * Properties are encapsulated inside [BubbleModifier] 16 | */ 17 | class BubbleLayout : FrameLayout { 18 | 19 | lateinit var modifier: BubbleModifier 20 | 21 | private val paint by lazy { 22 | Paint(Paint.ANTI_ALIAS_FLAG).apply { 23 | style = Paint.Style.FILL 24 | color = modifier.backgroundColor 25 | } 26 | } 27 | 28 | private val paintDebug by lazy { 29 | Paint(Paint.ANTI_ALIAS_FLAG).apply { 30 | style = Paint.Style.STROKE 31 | strokeWidth = 4f 32 | color = Color.RED 33 | } 34 | } 35 | 36 | /** 37 | * Rectangle for drawing content, this is the are that contains child views. Arrow is 38 | * excluded from this rectangle. 39 | */ 40 | private var rectContent = RectF() 41 | 42 | 43 | /** 44 | * Rectangle that covers content and arrow of the bubble layout. Total area is covered 45 | * in this rectangle. 46 | */ 47 | private var rectBubble = RectF() 48 | 49 | private var path = Path() 50 | 51 | /** 52 | * Setting this flag to true draws content and bubble rectangles around this layout 53 | */ 54 | var isDebug = false 55 | 56 | constructor(context: Context) : super(context) { 57 | modifier = BubbleModifier() 58 | init() 59 | } 60 | 61 | constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { 62 | val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BubbleLayout) 63 | 64 | modifier = BubbleModifier() 65 | init() 66 | 67 | modifier.backgroundColor = typedArray.getColor( 68 | R.styleable.BubbleLayout_android_background, 69 | modifier.backgroundColor 70 | ) 71 | modifier.cornerRadius = 72 | typedArray.getDimension( 73 | R.styleable.BubbleLayout_cornerRadius, 74 | modifier.cornerRadius 75 | ) 76 | 77 | modifier.cornerRadiusBundle.topLeft = typedArray.getDimension( 78 | R.styleable.BubbleLayout_cornerRadiusTopLeft, 79 | modifier.cornerRadius 80 | ) 81 | 82 | modifier.cornerRadiusBundle.topRight = typedArray.getDimension( 83 | R.styleable.BubbleLayout_cornerRadiusTopRight, 84 | modifier.cornerRadius 85 | ) 86 | 87 | modifier.cornerRadiusBundle.bottomRight = typedArray.getDimension( 88 | R.styleable.BubbleLayout_cornerRadiusBottomRight, 89 | modifier.cornerRadius 90 | ) 91 | 92 | modifier.cornerRadiusBundle.bottomLeft = typedArray.getDimension( 93 | R.styleable.BubbleLayout_cornerRadiusBottomLeft, 94 | modifier.cornerRadius 95 | ) 96 | 97 | // Arrow 98 | modifier.arrowAlignment = typedArray.getInt(R.styleable.BubbleLayout_arrowAlignment, NONE) 99 | 100 | modifier.arrowWidth = 101 | typedArray.getDimension(R.styleable.BubbleLayout_arrowWidth, modifier.arrowWidth) 102 | modifier.arrowHeight = 103 | typedArray.getDimension(R.styleable.BubbleLayout_arrowHeight, modifier.arrowHeight) 104 | modifier.arrowRadius = 105 | typedArray.getDimension(R.styleable.BubbleLayout_arrowRadius, modifier.arrowRadius) 106 | 107 | val arrowShape = 108 | typedArray.getInt(R.styleable.BubbleLayout_arrowShape, modifier.arrowShape.ordinal) 109 | 110 | modifier.arrowShape = when (arrowShape) { 111 | 0 -> ArrowShape.TRIANGLE_RIGHT 112 | 1 -> ArrowShape.TRIANGLE_ISOSCELES 113 | else -> ArrowShape.CURVED 114 | } 115 | modifier.arrowOffsetY = 116 | typedArray.getDimension(R.styleable.BubbleLayout_arrowOffsetY, modifier.arrowOffsetY) 117 | modifier.arrowOffsetX = 118 | typedArray.getDimension(R.styleable.BubbleLayout_arrowOffsetY, modifier.arrowOffsetX) 119 | modifier.withArrow = 120 | typedArray.getBoolean(R.styleable.BubbleLayout_withArrow, modifier.withArrow) 121 | 122 | // Elevation/Shadow 123 | val shadowStyle = 124 | typedArray.getInt(R.styleable.BubbleLayout_shadowStyle, modifier.shadowStyle.ordinal) 125 | 126 | modifier.shadowStyle = if (shadowStyle == ShadowStyle.ELEVATION.ordinal) { 127 | ShadowStyle.ELEVATION 128 | } else ShadowStyle.SHADOW 129 | 130 | 131 | if (modifier.shadowStyle == ShadowStyle.SHADOW) { 132 | modifier.shadowColor = 133 | typedArray.getColor( 134 | R.styleable.BubbleLayout_android_shadowColor, 135 | modifier.shadowColor 136 | ) 137 | modifier.shadowRadius = typedArray.getFloat( 138 | R.styleable.BubbleLayout_android_shadowRadius, 139 | modifier.shadowRadius 140 | ) 141 | modifier.shadowRadius = typedArray.getFloat( 142 | R.styleable.BubbleLayout_android_shadowDx, 143 | modifier.shadowOffsetX 144 | ) 145 | modifier.shadowRadius = typedArray.getFloat( 146 | R.styleable.BubbleLayout_android_shadowDy, 147 | modifier.shadowOffsetY 148 | ) 149 | } 150 | 151 | typedArray.recycle() 152 | 153 | } 154 | 155 | 156 | private fun init() { 157 | 158 | background = null 159 | 160 | modifier.dp = dp(1f) 161 | 162 | modifier.init() 163 | 164 | if (modifier.shadowStyle == ShadowStyle.SHADOW) { 165 | 166 | setLayerType(LAYER_TYPE_SOFTWARE, paint) 167 | paint.setShadowLayer( 168 | modifier.shadowRadius, 169 | modifier.shadowOffsetX, 170 | modifier.shadowOffsetY, 171 | modifier.shadowColor 172 | ) 173 | } 174 | 175 | setWillNotDraw(false) 176 | } 177 | 178 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 179 | 180 | val pair = measureBubbleLayout(widthMeasureSpec, heightMeasureSpec) 181 | 182 | val desiredWidth = pair.first 183 | val desiredHeight: Int = pair.second 184 | 185 | logMeasureSpecs("LOG: ", widthMeasureSpec, heightMeasureSpec) 186 | setMeasuredDimension(desiredWidth, desiredHeight) 187 | } 188 | 189 | private fun measureBubbleLayout( 190 | widthMeasureSpec: Int, 191 | heightMeasureSpec: Int 192 | ): Pair { 193 | 194 | // Set rectangle for content area, arrow is excluded, on 195 | val alignment = modifier.arrowAlignment 196 | 197 | val isHorizontalRightAligned = isHorizontalRightAligned(alignment) 198 | val isHorizontalLeftAligned = isHorizontalLeftAligned(alignment) 199 | val isVerticalBottomAligned = isVerticalBottomAligned(alignment) 200 | 201 | 202 | // Measure dimensions 203 | val widthMode = MeasureSpec.getMode(widthMeasureSpec) 204 | val widthSize = MeasureSpec.getSize(widthMeasureSpec) 205 | val heightMode = MeasureSpec.getMode(heightMeasureSpec) 206 | val heightSize = MeasureSpec.getSize(heightMeasureSpec) 207 | 208 | 209 | println("🎃 onMeasures widthSize: $widthSize, heightSize: $heightSize childCount: $childCount") 210 | 211 | var maxContentWidth: Int = 0 212 | var maxContentHeight: Int = 0 213 | 214 | // Measure children to get width and height for content area 215 | for (i in 0..childCount) { 216 | val child: View? = getChildAt(i) 217 | child?.measure(widthMeasureSpec, heightMeasureSpec) 218 | child?.let { child -> 219 | println( 220 | "🔥 onMeasure child #$i, " + 221 | "measuredWidth: ${child.measuredWidth}, width: ${child.width}, " + 222 | "measuredHeight: ${child.measuredHeight}, height: ${child.height}" 223 | ) 224 | 225 | if (child.measuredWidth > maxContentWidth) maxContentWidth = child.measuredWidth 226 | maxContentHeight += child.measuredHeight 227 | } 228 | } 229 | 230 | var desiredWidth = resolveSize( 231 | maxContentWidth, 232 | widthMeasureSpec 233 | ) + paddingStart + paddingEnd 234 | 235 | if (isHorizontalLeftAligned || isHorizontalRightAligned) { 236 | desiredWidth += modifier.arrowWidth.toInt() 237 | } 238 | 239 | var desiredHeight: Int = 240 | resolveSize(maxContentHeight, heightMeasureSpec) + paddingTop + paddingBottom 241 | if (isVerticalBottomAligned) desiredHeight += modifier.arrowHeight.toInt() 242 | 243 | when { 244 | isHorizontalLeftAligned -> { 245 | rectContent.set( 246 | modifier.arrowWidth, 247 | 0f, 248 | maxContentWidth.toFloat() + modifier.arrowWidth + paddingStart + paddingEnd, 249 | maxContentHeight.toFloat() + paddingTop + paddingBottom 250 | ) 251 | 252 | } 253 | 254 | isHorizontalRightAligned -> { 255 | rectContent.set( 256 | 0f, 257 | 0f, 258 | maxContentWidth.toFloat() + paddingStart + paddingEnd, 259 | maxContentHeight.toFloat() + paddingTop + paddingBottom 260 | ) 261 | 262 | } 263 | 264 | isVerticalBottomAligned -> { 265 | rectContent.set( 266 | 0f, 267 | 0f, 268 | maxContentWidth.toFloat() + paddingStart + paddingEnd, 269 | maxContentHeight.toFloat() + paddingTop + paddingBottom 270 | ) 271 | } 272 | 273 | else -> { 274 | rectContent.set( 275 | 0f, 276 | 0f, 277 | maxContentWidth.toFloat() + paddingStart + paddingEnd, 278 | maxContentHeight.toFloat() + paddingTop + paddingBottom 279 | ) 280 | } 281 | } 282 | 283 | 284 | rectBubble.set(0f, 0f, desiredWidth.toFloat(), desiredHeight.toFloat()) 285 | return Pair(desiredWidth, desiredHeight) 286 | } 287 | 288 | override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { 289 | super.onLayout(changed, left, top, right, bottom) 290 | layoutChildren(modifier) 291 | } 292 | 293 | private fun layoutChildren(modifier: BubbleModifier) { 294 | 295 | val alignment = modifier.arrowAlignment 296 | 297 | val isHorizontalRightAligned = isHorizontalRightAligned(alignment) 298 | val isHorizontalLeftAligned = isHorizontalLeftAligned(alignment) 299 | val isVerticalBottomAligned = isVerticalBottomAligned(alignment) 300 | 301 | when { 302 | 303 | // Arrow on left side 304 | isHorizontalLeftAligned -> { 305 | for (i in 0..childCount) { 306 | val child: View? = getChildAt(i) 307 | child?.let { child -> 308 | child.layout( 309 | (paddingStart + modifier.arrowWidth).toInt(), 310 | paddingTop, 311 | (paddingStart + child.width + modifier.arrowWidth).toInt(), 312 | paddingTop + child.height 313 | ) 314 | } 315 | } 316 | } 317 | 318 | // Arrow on right side 319 | isHorizontalRightAligned -> { 320 | 321 | for (i in 0..childCount) { 322 | val child: View? = getChildAt(i) 323 | child?.let { child -> 324 | child.layout( 325 | paddingStart, 326 | paddingTop, 327 | paddingStart + child.width, 328 | paddingTop + child.height 329 | ) 330 | } 331 | } 332 | } 333 | 334 | // Arrow at the bottom 335 | isVerticalBottomAligned -> { 336 | for (i in 0..childCount) { 337 | val child: View? = getChildAt(i) 338 | child?.let { child -> 339 | child.layout( 340 | paddingStart, 341 | paddingTop, 342 | paddingStart + child.width, 343 | (paddingTop + child.height + modifier.arrowHeight).toInt() 344 | ) 345 | } 346 | } 347 | } 348 | } 349 | } 350 | 351 | override fun onDraw(canvas: Canvas) { 352 | super.onDraw(canvas) 353 | 354 | // Get path for this bubble 355 | getBubbleClipPath( 356 | path, 357 | modifier = modifier, 358 | contentRect = rectContent, 359 | ) 360 | 361 | canvas.drawPath(path, paint) 362 | 363 | if (isDebug) { 364 | paintDebug.color = Color.RED 365 | canvas.drawRect(rectBubble, paintDebug) 366 | paintDebug.color = Color.BLUE 367 | canvas.drawRect(rectContent, paintDebug) 368 | } 369 | 370 | if (modifier.shadowStyle == ShadowStyle.ELEVATION) { 371 | outlineProvider = outlineProvider 372 | } 373 | } 374 | 375 | fun update(modifier: BubbleModifier) { 376 | this.modifier = modifier 377 | paint.color = modifier.backgroundColor 378 | invalidate() 379 | } 380 | 381 | override fun getOutlineProvider(): ViewOutlineProvider? { 382 | return object : ViewOutlineProvider() { 383 | override fun getOutline(view: View, outline: Outline) { 384 | try { 385 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { 386 | outline.setConvexPath(path) 387 | 388 | } else { 389 | outline.setPath(path) 390 | } 391 | } catch (e: Exception) { 392 | e.printStackTrace() 393 | } 394 | } 395 | } 396 | } 397 | } 398 | -------------------------------------------------------------------------------- /app/src/main/java/com/smarttoolfactory/bubblelayout/BubbleLinearLayout.kt: -------------------------------------------------------------------------------- 1 | package com.smarttoolfactory.bubblelayout 2 | 3 | import android.content.Context 4 | import android.graphics.* 5 | import android.os.Build 6 | import android.util.AttributeSet 7 | import android.view.View 8 | import android.view.ViewOutlineProvider 9 | import android.widget.FrameLayout 10 | import android.widget.LinearLayout 11 | 12 | /** 13 | * Linear layout that draws chat or speech bubble with specified properties. 14 | * 15 | * Properties are encapsulated inside [BubbleModifier] 16 | */ 17 | class BubbleLinearLayout : FrameLayout { 18 | 19 | lateinit var modifier: BubbleModifier 20 | 21 | private val paint by lazy { 22 | Paint(Paint.ANTI_ALIAS_FLAG).apply { 23 | style = Paint.Style.FILL 24 | color = modifier.backgroundColor 25 | } 26 | } 27 | 28 | private val paintDebug by lazy { 29 | Paint(Paint.ANTI_ALIAS_FLAG).apply { 30 | style = Paint.Style.STROKE 31 | strokeWidth = 4f 32 | color = Color.RED 33 | } 34 | } 35 | 36 | /** 37 | * Rectangle for drawing content, this is the are that contains child views. Arrow is 38 | * excluded from this rectangle. 39 | */ 40 | private var rectContent = RectF() 41 | 42 | 43 | /** 44 | * Rectangle that covers content and arrow of the bubble layout. Total area is covered 45 | * in this rectangle. 46 | */ 47 | private var rectBubble = RectF() 48 | 49 | private var path = Path() 50 | 51 | /** 52 | * Setting this flag to true draws content and bubble rectangles around this layout 53 | */ 54 | var isDebug = false 55 | 56 | constructor(context: Context) : super(context) { 57 | modifier = BubbleModifier() 58 | init() 59 | } 60 | 61 | constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { 62 | val typedArray = context.obtainStyledAttributes(attrs, R.styleable.BubbleLayout) 63 | 64 | modifier = BubbleModifier() 65 | init() 66 | 67 | modifier.backgroundColor = typedArray.getColor( 68 | R.styleable.BubbleLayout_android_background, 69 | modifier.backgroundColor 70 | ) 71 | modifier.cornerRadius = 72 | typedArray.getDimension( 73 | R.styleable.BubbleLayout_cornerRadius, 74 | modifier.cornerRadius 75 | ) 76 | 77 | modifier.cornerRadiusBundle.topLeft = typedArray.getDimension( 78 | R.styleable.BubbleLayout_cornerRadiusTopLeft, 79 | modifier.cornerRadius 80 | ) 81 | 82 | modifier.cornerRadiusBundle.topRight = typedArray.getDimension( 83 | R.styleable.BubbleLayout_cornerRadiusTopRight, 84 | modifier.cornerRadius 85 | ) 86 | 87 | modifier.cornerRadiusBundle.bottomRight = typedArray.getDimension( 88 | R.styleable.BubbleLayout_cornerRadiusBottomRight, 89 | modifier.cornerRadius 90 | ) 91 | 92 | modifier.cornerRadiusBundle.bottomLeft = typedArray.getDimension( 93 | R.styleable.BubbleLayout_cornerRadiusBottomLeft, 94 | modifier.cornerRadius 95 | ) 96 | 97 | // Arrow 98 | modifier.arrowAlignment = typedArray.getInt(R.styleable.BubbleLayout_arrowAlignment, NONE) 99 | 100 | modifier.arrowWidth = 101 | typedArray.getDimension(R.styleable.BubbleLayout_arrowWidth, modifier.arrowWidth) 102 | modifier.arrowHeight = 103 | typedArray.getDimension(R.styleable.BubbleLayout_arrowHeight, modifier.arrowHeight) 104 | modifier.arrowRadius = 105 | typedArray.getDimension(R.styleable.BubbleLayout_arrowRadius, modifier.arrowRadius) 106 | 107 | val arrowShape = 108 | typedArray.getInt(R.styleable.BubbleLayout_arrowShape, modifier.arrowShape.ordinal) 109 | 110 | modifier.arrowShape = when (arrowShape) { 111 | 0 -> ArrowShape.TRIANGLE_RIGHT 112 | 1 -> ArrowShape.TRIANGLE_ISOSCELES 113 | else -> ArrowShape.CURVED 114 | } 115 | modifier.arrowOffsetY = 116 | typedArray.getDimension(R.styleable.BubbleLayout_arrowOffsetY, modifier.arrowOffsetY) 117 | modifier.arrowOffsetX = 118 | typedArray.getDimension(R.styleable.BubbleLayout_arrowOffsetY, modifier.arrowOffsetX) 119 | modifier.withArrow = 120 | typedArray.getBoolean(R.styleable.BubbleLayout_withArrow, modifier.withArrow) 121 | 122 | // Elevation/Shadow 123 | val shadowStyle = 124 | typedArray.getInt(R.styleable.BubbleLayout_shadowStyle, modifier.shadowStyle.ordinal) 125 | 126 | modifier.shadowStyle = if (shadowStyle == ShadowStyle.ELEVATION.ordinal) { 127 | ShadowStyle.ELEVATION 128 | } else ShadowStyle.SHADOW 129 | 130 | 131 | if (modifier.shadowStyle == ShadowStyle.SHADOW) { 132 | modifier.shadowColor = 133 | typedArray.getColor( 134 | R.styleable.BubbleLayout_android_shadowColor, 135 | modifier.shadowColor 136 | ) 137 | modifier.shadowRadius = typedArray.getFloat( 138 | R.styleable.BubbleLayout_android_shadowRadius, 139 | modifier.shadowRadius 140 | ) 141 | modifier.shadowRadius = typedArray.getFloat( 142 | R.styleable.BubbleLayout_android_shadowDx, 143 | modifier.shadowOffsetX 144 | ) 145 | modifier.shadowRadius = typedArray.getFloat( 146 | R.styleable.BubbleLayout_android_shadowDy, 147 | modifier.shadowOffsetY 148 | ) 149 | } 150 | 151 | typedArray.recycle() 152 | 153 | } 154 | 155 | private fun init() { 156 | 157 | background = null 158 | 159 | modifier.dp = dp(1f) 160 | 161 | modifier.init() 162 | 163 | if (modifier.shadowStyle == ShadowStyle.SHADOW) { 164 | 165 | setLayerType(LAYER_TYPE_SOFTWARE, paint) 166 | paint.setShadowLayer( 167 | modifier.shadowRadius, 168 | modifier.shadowOffsetX, 169 | modifier.shadowOffsetY, 170 | modifier.shadowColor 171 | ) 172 | } 173 | 174 | setWillNotDraw(false) 175 | } 176 | 177 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 178 | 179 | val pair = measureBubbleLayout(widthMeasureSpec, heightMeasureSpec) 180 | 181 | val desiredWidth = pair.first 182 | val desiredHeight: Int = pair.second 183 | 184 | logMeasureSpecs("LOG: ", widthMeasureSpec, heightMeasureSpec) 185 | setMeasuredDimension(desiredWidth, desiredHeight) 186 | } 187 | 188 | private fun measureBubbleLayout( 189 | widthMeasureSpec: Int, 190 | heightMeasureSpec: Int 191 | ): Pair { 192 | 193 | // Set rectangle for content area, arrow is excluded, on 194 | val alignment = modifier.arrowAlignment 195 | 196 | val isHorizontalRightAligned = isHorizontalRightAligned(alignment) 197 | val isHorizontalLeftAligned = isHorizontalLeftAligned(alignment) 198 | val isVerticalBottomAligned = isVerticalBottomAligned(alignment) 199 | 200 | 201 | // Measure dimensions 202 | val widthMode = MeasureSpec.getMode(widthMeasureSpec) 203 | val widthSize = MeasureSpec.getSize(widthMeasureSpec) 204 | val heightMode = MeasureSpec.getMode(heightMeasureSpec) 205 | val heightSize = MeasureSpec.getSize(heightMeasureSpec) 206 | 207 | 208 | println("🎃 onMeasures widthSize: $widthSize, heightSize: $heightSize childCount: $childCount") 209 | 210 | var maxContentWidth: Int = 0 211 | var maxContentHeight: Int = 0 212 | 213 | // Measure children to get width and height for content area 214 | for (i in 0..childCount) { 215 | val child: View? = getChildAt(i) 216 | child?.measure(widthMeasureSpec, heightMeasureSpec) 217 | child?.let { child -> 218 | println( 219 | "🔥 onMeasure child #$i, " + 220 | "measuredWidth: ${child.measuredWidth}, width: ${child.width}, " + 221 | "measuredHeight: ${child.measuredHeight}, height: ${child.height}" 222 | ) 223 | 224 | if (child.measuredWidth > maxContentWidth) maxContentWidth = child.measuredWidth 225 | maxContentHeight += child.measuredHeight 226 | } 227 | } 228 | 229 | var desiredWidth = resolveSize( 230 | maxContentWidth, 231 | widthMeasureSpec 232 | ) + paddingStart + paddingEnd 233 | 234 | if (isHorizontalLeftAligned || isHorizontalRightAligned) { 235 | desiredWidth += modifier.arrowWidth.toInt() 236 | } 237 | 238 | var desiredHeight: Int = 239 | resolveSize(maxContentHeight, heightMeasureSpec) + paddingTop + paddingBottom 240 | if (isVerticalBottomAligned) desiredHeight += modifier.arrowHeight.toInt() 241 | 242 | when { 243 | isHorizontalLeftAligned -> { 244 | rectContent.set( 245 | modifier.arrowWidth, 246 | 0f, 247 | maxContentWidth.toFloat() + modifier.arrowWidth + paddingStart + paddingEnd, 248 | maxContentHeight.toFloat() + paddingTop + paddingBottom 249 | ) 250 | 251 | } 252 | 253 | isHorizontalRightAligned -> { 254 | rectContent.set( 255 | 0f, 256 | 0f, 257 | maxContentWidth.toFloat() + paddingStart + paddingEnd, 258 | maxContentHeight.toFloat() + paddingTop + paddingBottom 259 | ) 260 | 261 | } 262 | 263 | isVerticalBottomAligned -> { 264 | rectContent.set( 265 | 0f, 266 | 0f, 267 | maxContentWidth.toFloat() + paddingStart + paddingEnd, 268 | maxContentHeight.toFloat() + paddingTop + paddingBottom 269 | ) 270 | } 271 | 272 | else -> { 273 | rectContent.set( 274 | 0f, 275 | 0f, 276 | maxContentWidth.toFloat() + paddingStart + paddingEnd, 277 | maxContentHeight.toFloat() + paddingTop + paddingBottom 278 | ) 279 | } 280 | } 281 | 282 | 283 | rectBubble.set(0f, 0f, desiredWidth.toFloat(), desiredHeight.toFloat()) 284 | return Pair(desiredWidth, desiredHeight) 285 | } 286 | 287 | override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { 288 | super.onLayout(changed, left, top, right, bottom) 289 | layoutChildren(modifier) 290 | } 291 | 292 | private fun layoutChildren(modifier: BubbleModifier) { 293 | 294 | val alignment = modifier.arrowAlignment 295 | 296 | val isHorizontalRightAligned = isHorizontalRightAligned(alignment) 297 | val isHorizontalLeftAligned = isHorizontalLeftAligned(alignment) 298 | val isVerticalBottomAligned = isVerticalBottomAligned(alignment) 299 | 300 | var childTop = 0 301 | 302 | when { 303 | 304 | // Arrow on left side 305 | isHorizontalLeftAligned -> { 306 | for (i in 0..childCount) { 307 | val child: View? = getChildAt(i) 308 | child?.let { child -> 309 | 310 | 311 | child.layout( 312 | (paddingStart + modifier.arrowWidth).toInt(), 313 | childTop + paddingTop, 314 | (childTop + paddingStart + child.width + modifier.arrowWidth).toInt(), 315 | paddingTop + child.height 316 | ) 317 | 318 | childTop += child.height 319 | } 320 | 321 | } 322 | } 323 | 324 | // Arrow on right side 325 | isHorizontalRightAligned -> { 326 | 327 | for (i in 0..childCount) { 328 | val child: View? = getChildAt(i) 329 | child?.let { child -> 330 | child.layout( 331 | paddingStart, 332 | childTop + paddingTop, 333 | paddingStart + child.width, 334 | childTop + paddingTop + child.height 335 | ) 336 | 337 | childTop += child.height 338 | } 339 | } 340 | } 341 | 342 | // Arrow at the bottom 343 | isVerticalBottomAligned -> { 344 | for (i in 0..childCount) { 345 | val child: View? = getChildAt(i) 346 | child?.let { child -> 347 | child.layout( 348 | paddingStart, 349 | paddingTop, 350 | paddingStart + child.width, 351 | (childTop + paddingTop + child.height + modifier.arrowHeight).toInt() 352 | ) 353 | 354 | childTop += child.height 355 | } 356 | } 357 | } 358 | } 359 | } 360 | 361 | override fun onDraw(canvas: Canvas) { 362 | super.onDraw(canvas) 363 | 364 | // Get path for this bubble 365 | getBubbleClipPath( 366 | path, 367 | modifier = modifier, 368 | contentRect = rectContent, 369 | ) 370 | 371 | canvas.drawPath(path, paint) 372 | 373 | if (isDebug) { 374 | paintDebug.color = Color.RED 375 | canvas.drawRect(rectBubble, paintDebug) 376 | paintDebug.color = Color.BLUE 377 | canvas.drawRect(rectContent, paintDebug) 378 | } 379 | 380 | if (modifier.shadowStyle == ShadowStyle.ELEVATION) { 381 | outlineProvider = outlineProvider 382 | } 383 | } 384 | 385 | fun update(modifier: BubbleModifier) { 386 | this.modifier = modifier 387 | paint.color = modifier.backgroundColor 388 | invalidate() 389 | } 390 | 391 | override fun getOutlineProvider(): ViewOutlineProvider? { 392 | return object : ViewOutlineProvider() { 393 | override fun getOutline(view: View, outline: Outline) { 394 | try { 395 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { 396 | outline.setConvexPath(path) 397 | 398 | } else { 399 | outline.setPath(path) 400 | } 401 | } catch (e: Exception) { 402 | e.printStackTrace() 403 | } 404 | } 405 | } 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /app/src/main/java/com/smarttoolfactory/bubblelayout/BubbleModifier.kt: -------------------------------------------------------------------------------- 1 | package com.smarttoolfactory.bubblelayout 2 | 3 | import android.app.Activity 4 | import android.graphics.Color 5 | import android.graphics.Paint 6 | import android.view.View 7 | import androidx.fragment.app.Fragment 8 | 9 | class BubbleModifier { 10 | 11 | /** 12 | * Scale to set initial values as dp 13 | */ 14 | internal var dp: Float = 1f 15 | 16 | /** 17 | * Background of Bubble 18 | */ 19 | var backgroundColor: Int = Color.rgb(220, 248, 198) 20 | 21 | /** 22 | * Corner radius of bubble layout for y axis 23 | */ 24 | var cornerRadius = 8f 25 | set(value) { 26 | cornerRadiusBundle.topLeft = cornerRadius 27 | cornerRadiusBundle.topRight = cornerRadius 28 | cornerRadiusBundle.bottomRight = cornerRadius 29 | cornerRadiusBundle.bottomLeft = cornerRadius 30 | field = value 31 | } 32 | 33 | /** 34 | * Custom corner radius for each side of the rectangle, if this is not null parameters 35 | * of this data class is used to draw rounded rectangle. 36 | */ 37 | var cornerRadiusBundle: CornerRadius = CornerRadius( 38 | topLeft = cornerRadius, 39 | topRight = cornerRadius, 40 | bottomLeft = cornerRadius, 41 | bottomRight = cornerRadius 42 | ) 43 | 44 | /** 45 | * Arrow alignment determines in which side of the bubble this arrow should be drawn. 46 | * When [NONE] is selected no arrow is drawn 47 | */ 48 | var arrowAlignment: Int = NONE 49 | 50 | 51 | /** 52 | * Top position of arrow 53 | */ 54 | var arrowTop: Float = 0f 55 | 56 | /** 57 | * Bottom position of arrow 58 | */ 59 | var arrowBottom = 0f 60 | 61 | var arrowWidth: Float = 14.0f 62 | var arrowHeight: Float = 14.0f 63 | var arrowRadius: Float = 0.0f 64 | 65 | var arrowShape: ArrowShape = ArrowShape.TRIANGLE_RIGHT 66 | 67 | /** 68 | * Vertical offset for arrow that is positioned on left or right side of the bubble. 69 | * 70 | * Positive values move arrow bottom while negative values move up. Arrow position 71 | * is limited between top of content and content bottom minus arrow height. 72 | */ 73 | var arrowOffsetY: Float = 0f 74 | 75 | 76 | /** 77 | * Vertical offset for arrow that is positioned on top or at the bottom of the bubble. 78 | * 79 | * Positive values move arrow right while negative values move left. Arrow position 80 | * is limited between left of content and content right minus arrow width. 81 | */ 82 | var arrowOffsetX: Float = 0f 83 | 84 | 85 | /** 86 | * If set to true an arrow is drawn depending on it's alignment, horizontal and vertical 87 | * offset. 88 | */ 89 | var withArrow = true 90 | 91 | /** 92 | * Select how shadow of this bubble should be drawn. If [ShadowStyle.ELEVATION] 93 | * 94 | * set elevation in xml or programmatically. If [ShadowStyle.SHADOW] is selected 95 | * it adds shadow layer to [Paint] 96 | */ 97 | var shadowStyle: ShadowStyle = ShadowStyle.ELEVATION 98 | 99 | /** 100 | * This only effect when shadow style is [ShadowStyle.SHADOW] 101 | */ 102 | var shadowColor: Int = Color.argb(55, 55, 55, 55) 103 | 104 | var shadowRadius: Float = 0f 105 | 106 | var shadowOffsetX: Float = 0f 107 | 108 | var shadowOffsetY: Float = 0f 109 | 110 | fun init() { 111 | 112 | cornerRadius *= dp 113 | 114 | cornerRadiusBundle.apply { 115 | topLeft = cornerRadius 116 | topRight = cornerRadius 117 | bottomLeft = cornerRadius 118 | bottomRight = cornerRadius 119 | } 120 | 121 | arrowWidth *= dp 122 | arrowHeight *= dp 123 | arrowRadius *= dp 124 | arrowOffsetY *= dp 125 | arrowOffsetX *= dp 126 | 127 | shadowRadius *= dp 128 | shadowOffsetX *= dp 129 | shadowOffsetY *= dp 130 | } 131 | } 132 | 133 | class CornerRadius( 134 | var topLeft: Float = 0f, 135 | var topRight: Float = 0f, 136 | var bottomLeft: Float = 0f, 137 | var bottomRight: Float = 0f 138 | ) 139 | 140 | enum class ShadowStyle { 141 | ELEVATION, 142 | SHADOW 143 | } 144 | 145 | enum class ArrowShape { 146 | TRIANGLE_RIGHT, 147 | TRIANGLE_ISOSCELES, 148 | CURVED 149 | } 150 | 151 | const val NONE = 0 152 | const val LEFT_TOP = 1 153 | const val LEFT_CENTER = 2 154 | const val LEFT_BOTTOM = 3 155 | const val RIGHT_TOP = 4 156 | const val RIGHT_CENTER = 5 157 | const val RIGHT_BOTTOM = 6 158 | const val BOTTOM_LEFT = 7 159 | const val BOTTOM_CENTER = 8 160 | const val BOTTOM_RIGHT = 9 161 | 162 | fun Activity.dp(dpValue: Float): Float { 163 | return try { 164 | val scale = resources.displayMetrics.density 165 | (dpValue * scale + 0.5f) 166 | } catch (e: Exception) { 167 | (dpValue + 0.5f) 168 | } 169 | } 170 | 171 | fun Fragment.dp(dpValue: Float): Float { 172 | return try { 173 | val scale = resources.displayMetrics.density 174 | (dpValue * scale + 0.5f) 175 | } catch (e: Exception) { 176 | (dpValue + 0.5f) 177 | } 178 | } 179 | 180 | fun View.dp(dpValue: Float): Float { 181 | return try { 182 | val scale = resources.displayMetrics.density 183 | (dpValue * scale + 0.5f) 184 | } catch (e: Exception) { 185 | (dpValue + 0.5f) 186 | } 187 | } 188 | 189 | fun Activity.dp(dpValue: Int): Int { 190 | return try { 191 | val scale = resources.displayMetrics.density 192 | (dpValue * scale + 0.5f) 193 | } catch (e: Exception) { 194 | (dpValue + 0.5f) 195 | }.toInt() 196 | } 197 | 198 | fun Fragment.dp(dpValue: Int): Int { 199 | return try { 200 | val scale = resources.displayMetrics.density 201 | (dpValue * scale + 0.5f) 202 | } catch (e: Exception) { 203 | (dpValue + 0.5f) 204 | }.toInt() 205 | } 206 | 207 | fun View.dp(dpValue: Int): Int { 208 | return try { 209 | val scale = resources.displayMetrics.density 210 | (dpValue * scale + 0.5f) 211 | } catch (e: Exception) { 212 | (dpValue + 0.5f) 213 | }.toInt() 214 | } 215 | -------------------------------------------------------------------------------- /app/src/main/java/com/smarttoolfactory/bubblelayout/CurveDrawView.kt: -------------------------------------------------------------------------------- 1 | package com.smarttoolfactory.bubblelayout 2 | 3 | import android.content.Context 4 | import android.graphics.* 5 | import android.util.AttributeSet 6 | import android.view.View 7 | 8 | 9 | class CurveDrawView : View { 10 | 11 | private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { 12 | style = Paint.Style.FILL 13 | strokeWidth = 6f 14 | color = Color.RED 15 | } 16 | 17 | 18 | private var path = Path() 19 | 20 | constructor(context: Context) : super(context) 21 | 22 | constructor(context: Context, attrs: AttributeSet) : super(context, attrs) 23 | 24 | override fun onDraw(canvas: Canvas) { 25 | super.onDraw(canvas) 26 | 27 | // path.moveTo(0f,0f) 28 | // path.quadTo(50f,120f, 190f, 190f) 29 | // path.quadTo(190f,190f, 185f, 200f) 30 | // path.lineTo(0f, 200f) 31 | // canvas.drawPath(path, paint) 32 | 33 | val x1 = 30f 34 | val y1 = 30f 35 | 36 | val xfinal = 200f 37 | val yfinal = 200f 38 | 39 | val x3 = (xfinal - x1) 40 | val y3 = (yfinal - y1) * .9f 41 | 42 | val x2 = (x1 + x3) / 4 43 | val y2 = (y1 + y3) * 3 / 4 44 | 45 | 46 | path.moveTo(x1, y1) 47 | 48 | path.cubicTo(x1, y1, x2, y2, x3, y3) 49 | // path.quadTo(x3,y3,x3 - 20,yfinal) 50 | path.cubicTo(x3, y3, xfinal, yfinal, x3, yfinal) 51 | path.lineTo(x1, yfinal) 52 | path.close() 53 | canvas.drawPath(path, paint) 54 | 55 | paint.color = Color.YELLOW 56 | canvas.drawLine(x1, y1, x2, y2, paint) 57 | paint.color = Color.GREEN 58 | canvas.drawLine(x2, y2, x3, y3, paint) 59 | paint.color = Color.BLUE 60 | canvas.drawLine(x3, y3, xfinal, yfinal, paint) 61 | 62 | 63 | // path.moveTo(50f, 50f) 64 | // path.cubicTo(300f, 50f, 100f, 400f, 400f, 400f) 65 | // canvas.drawPath(path, paint) 66 | 67 | 68 | canvas.drawCircle(500f, 500f, 50f, paint) 69 | 70 | val rect = RectF() 71 | 72 | rect.set(width.toFloat() - 50f, 300f, width.toFloat(), 330f) 73 | 74 | path.moveTo(width.toFloat(), 300f) 75 | path.lineTo(width.toFloat()- 50f, 300f) 76 | path.addArc(rect, 270f, -180f) 77 | path.lineTo(width.toFloat(), 330f) 78 | 79 | paint.style = Paint.Style.STROKE 80 | paint.color = Color.RED 81 | canvas.drawRect(rect, paint) 82 | paint.color = Color.YELLOW 83 | canvas.drawPath(path, paint) 84 | 85 | } 86 | 87 | 88 | private fun drawLeftBottomArc(canvas: Canvas) { 89 | val x1 = 0f 90 | val y1 = 0f 91 | 92 | val xfinal = 200f 93 | val yfinal = 200f 94 | 95 | val x3 = xfinal * .9f 96 | val y3 = yfinal * .9f 97 | 98 | val x2 = (x1 + x3) / 4 99 | val y2 = (y1 + y3) * 3 / 4 100 | 101 | 102 | val r = yfinal - y3 103 | 104 | path.moveTo(x1, y1) 105 | path.cubicTo(x1, y1, x2, y2, x3, y3) 106 | path.cubicTo(x3, y3, xfinal, yfinal, x3 * .95f, yfinal) 107 | path.lineTo(x1, yfinal) 108 | path.close() 109 | canvas.drawPath(path, paint) 110 | } 111 | 112 | } -------------------------------------------------------------------------------- /app/src/main/java/com/smarttoolfactory/bubblelayout/LogMeasurementSpecs.kt: -------------------------------------------------------------------------------- 1 | package com.smarttoolfactory.bubblelayout 2 | 3 | import android.util.Log 4 | import android.view.View 5 | 6 | internal fun logMeasureSpecs(text: String, widthMeasureSpec: Int, heightMeasureSpec: Int) { 7 | 8 | val widthMode = View.MeasureSpec.getMode(widthMeasureSpec) 9 | val width = View.MeasureSpec.getSize(widthMeasureSpec) 10 | val heightMode = View.MeasureSpec.getMode(heightMeasureSpec) 11 | val height = View.MeasureSpec.getSize(heightMeasureSpec) 12 | 13 | val measureSpecHeight: String = when (heightMode) { 14 | View.MeasureSpec.EXACTLY -> { 15 | "EXACTLY" 16 | } 17 | View.MeasureSpec.AT_MOST -> { 18 | "AT_MOST" 19 | } 20 | else -> { 21 | "UNSPECIFIED" 22 | } 23 | } 24 | val measureSpecWidth: String = when (widthMode) { 25 | View.MeasureSpec.EXACTLY -> { 26 | "EXACTLY" 27 | } 28 | View.MeasureSpec.AT_MOST -> { 29 | "AT_MOST" 30 | } 31 | else -> { 32 | "UNSPECIFIED" 33 | } 34 | } 35 | val TAG = "LogSpecs" 36 | 37 | Log.d( 38 | TAG, "TEXT: $text, Width: " + measureSpecWidth + ", " + width + " Height: " 39 | + measureSpecHeight + ", " + height 40 | ) 41 | 42 | println( 43 | "TEXT: $text, Width: " + measureSpecWidth + ", " + width + " Height: " 44 | + measureSpecHeight + ", " + height 45 | ) 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/smarttoolfactory/bubblelayout/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.smarttoolfactory.bubblelayout 2 | 3 | import android.graphics.Color 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatActivity 6 | 7 | class MainActivity : AppCompatActivity() { 8 | 9 | override fun onCreate(savedInstanceState: Bundle?) { 10 | super.onCreate(savedInstanceState) 11 | 12 | /* 13 | Alternative 1- Draw bubbles using Modifier programmatically 14 | */ 15 | // setContentView(R.layout.activity_main) 16 | // drawBubbles() 17 | 18 | /* 19 | Alternative 2- Draw bubbles with XML properties 20 | */ 21 | setContentView(R.layout.activity_main_bubble_linear_with_attrs) 22 | } 23 | 24 | private fun drawBubbles() { 25 | 26 | val dateBubble = findViewById(R.id.bubbleDate) 27 | dateBubble.modifier.apply { 28 | arrowAlignment = NONE 29 | backgroundColor = Color.rgb(212, 234, 244) 30 | } 31 | dateBubble.update(modifier = dateBubble.modifier) 32 | 33 | dateBubble.paddingStart 34 | 35 | /* 36 | Sent bubble 37 | */ 38 | val senderBubble1 = findViewById(R.id.bubbleViewSender) 39 | senderBubble1.modifier.apply { 40 | arrowAlignment = RIGHT_TOP 41 | arrowOffsetY = dp(5f) 42 | } 43 | 44 | senderBubble1.update(modifier = senderBubble1.modifier) 45 | 46 | 47 | val senderBubble2 = findViewById(R.id.bubbleViewSender2) 48 | senderBubble2.modifier.apply { 49 | arrowAlignment = RIGHT_BOTTOM 50 | } 51 | 52 | senderBubble2.update(modifier = senderBubble2.modifier) 53 | 54 | val senderBubble3 = findViewById(R.id.bubbleViewSender3) 55 | senderBubble3.modifier.apply { 56 | arrowAlignment = RIGHT_BOTTOM 57 | withArrow = false 58 | } 59 | 60 | senderBubble3.update(modifier = senderBubble3.modifier) 61 | 62 | 63 | val senderBubble4 = findViewById(R.id.bubbleViewSender4) 64 | senderBubble4.modifier.apply { 65 | arrowAlignment = RIGHT_CENTER 66 | arrowShape = ArrowShape.TRIANGLE_ISOSCELES 67 | } 68 | 69 | senderBubble4.update(modifier = senderBubble4.modifier) 70 | 71 | /* 72 | Received Bubbles 73 | */ 74 | val receiverBubble = findViewById(R.id.bubbleViewReceiver) 75 | 76 | val modifierR1 = receiverBubble.modifier.apply { 77 | backgroundColor = Color.WHITE 78 | arrowAlignment = LEFT_BOTTOM 79 | arrowOffsetY = -dp(5f) 80 | } 81 | 82 | receiverBubble.update(modifierR1) 83 | 84 | val receiverBubble2 = findViewById(R.id.bubbleViewReceiver2) 85 | 86 | val modifierR2 = receiverBubble2.modifier.apply { 87 | backgroundColor = Color.WHITE 88 | arrowAlignment = LEFT_TOP 89 | } 90 | 91 | receiverBubble2.update(modifierR2) 92 | 93 | 94 | val receiverBubble3 = findViewById(R.id.bubbleViewReceiver3) 95 | 96 | val modifierR3 = receiverBubble3.modifier.apply { 97 | backgroundColor = Color.WHITE 98 | arrowAlignment = LEFT_TOP 99 | withArrow = false 100 | } 101 | 102 | receiverBubble3.update(modifierR3) 103 | 104 | 105 | val receiverBubble4 = findViewById(R.id.bubbleViewReceiver4) 106 | 107 | val modifierR4 = receiverBubble4.modifier.apply { 108 | arrowHeight = 24f.dp 109 | backgroundColor = Color.WHITE 110 | arrowAlignment = LEFT_CENTER 111 | arrowShape = ArrowShape.TRIANGLE_ISOSCELES 112 | } 113 | 114 | receiverBubble4.update(modifierR4) 115 | 116 | 117 | /* 118 | Bottom Bubbles 119 | */ 120 | val bubbleBottom1 = findViewById(R.id.bubbleViewBottom1) 121 | 122 | val modifierBottom1 = bubbleBottom1.modifier.apply { 123 | backgroundColor = Color.rgb(251, 192, 45) 124 | arrowAlignment = BOTTOM_CENTER 125 | arrowShape = ArrowShape.TRIANGLE_ISOSCELES 126 | 127 | } 128 | bubbleBottom1.update(modifierBottom1) 129 | 130 | val bubbleBottom2 = findViewById(R.id.bubbleViewBottom2) 131 | val modifierBottom2 = bubbleBottom2.modifier.apply { 132 | backgroundColor = Color.rgb(142, 36, 170) 133 | arrowAlignment = BOTTOM_LEFT 134 | arrowShape = ArrowShape.TRIANGLE_RIGHT 135 | arrowWidth = dp(48f) 136 | arrowHeight = dp(16f) 137 | } 138 | bubbleBottom2.update(modifierBottom2) 139 | 140 | val bubbleBottom3 = findViewById(R.id.bubbleViewBottom3) 141 | val modifierBottom3 = bubbleBottom3.modifier.apply { 142 | backgroundColor = Color.rgb(0, 121, 107) 143 | arrowAlignment = BOTTOM_RIGHT 144 | arrowShape = ArrowShape.TRIANGLE_RIGHT 145 | } 146 | bubbleBottom3.update(modifierBottom3) 147 | 148 | 149 | /* 150 | Custom corner radius 151 | */ 152 | 153 | val bubbleCustomRad1 = findViewById(R.id.bubbleCustomRad1) 154 | 155 | val modifierCR1 = bubbleCustomRad1.modifier.apply { 156 | backgroundColor = Color.rgb(92, 107, 192) 157 | arrowAlignment = LEFT_TOP 158 | withArrow = false 159 | cornerRadiusBundle = CornerRadius( 160 | topLeft = dp(24f), 161 | topRight = dp(16f), 162 | bottomLeft = dp(2f), 163 | bottomRight = dp(16f) 164 | ) 165 | } 166 | 167 | bubbleCustomRad1.update(modifierCR1) 168 | 169 | val bubbleCustomRad2 = findViewById(R.id.bubbleCustomRad2) 170 | 171 | val modifierCR2 = bubbleCustomRad2.modifier.apply { 172 | backgroundColor = Color.rgb(92, 107, 192) 173 | arrowAlignment = LEFT_TOP 174 | withArrow = false 175 | 176 | cornerRadiusBundle = CornerRadius( 177 | topLeft = dp(2f), 178 | topRight = dp(16f), 179 | bottomLeft = dp(2f), 180 | bottomRight = dp(16f) 181 | ) 182 | } 183 | bubbleCustomRad2.update(modifierCR2) 184 | 185 | val bubbleCustomRad3 = findViewById(R.id.bubbleCustomRad3) 186 | val modifierCR3 = bubbleCustomRad3.modifier.apply { 187 | backgroundColor = Color.rgb(92, 107, 192) 188 | arrowAlignment = LEFT_TOP 189 | withArrow = false 190 | cornerRadiusBundle = CornerRadius( 191 | topLeft = dp(2f), 192 | topRight = dp(16f), 193 | bottomLeft = dp(8f), 194 | bottomRight = dp(16f) 195 | ) 196 | } 197 | bubbleCustomRad3.update(modifierCR3) 198 | 199 | } 200 | 201 | private val Float.dp 202 | get() = this@MainActivity.dp(this) 203 | 204 | private val Int.dp 205 | get() = this@MainActivity.dp(this) 206 | } -------------------------------------------------------------------------------- /app/src/main/java/com/smarttoolfactory/bubblelayout/Util.kt: -------------------------------------------------------------------------------- 1 | package com.smarttoolfactory.bubblelayout 2 | 3 | import android.graphics.Path 4 | import android.graphics.RectF 5 | import kotlin.math.min 6 | 7 | 8 | /** 9 | * Function that returns bubble path. 10 | * 11 | * @param modifier sum of properties of this layout which includes arrow alignemnt, position,etc. 12 | * @param contentRect rectangle of content area 13 | * 14 | */ 15 | fun getBubbleClipPath( 16 | path: Path, 17 | modifier: BubbleModifier, 18 | contentRect: RectF 19 | ) { 20 | 21 | path.reset() 22 | 23 | 24 | if (modifier.withArrow) { 25 | if (isArrowHorizontalPosition(modifier.arrowAlignment)) { 26 | createHorizontalArrowPath( 27 | path = path, 28 | contentRect = contentRect, 29 | modifier = modifier 30 | ) 31 | } else if (isArrowVerticalPosition(modifier.arrowAlignment)) { 32 | createVerticalArrowPath( 33 | path = path, 34 | contentRect = contentRect, 35 | modifier = modifier 36 | ) 37 | } 38 | } 39 | 40 | getRoundedRectPath(modifier, path, contentRect) 41 | } 42 | 43 | private fun getRoundedRectPath( 44 | modifier: BubbleModifier, 45 | path: Path, 46 | contentRect: RectF 47 | ) { 48 | 49 | val alignment = modifier.arrowAlignment 50 | 51 | val cornerRadius = modifier.cornerRadiusBundle 52 | 53 | val maxRadius = contentRect.height() / 2f 54 | 55 | cornerRadius.apply { 56 | topLeft = min(topLeft, maxRadius) 57 | topRight = min(topRight, maxRadius) 58 | bottomRight = min(bottomRight, maxRadius) 59 | bottomLeft = min(bottomLeft, maxRadius) 60 | } 61 | 62 | val isWithArrow = modifier.withArrow 63 | 64 | 65 | if (isWithArrow) { 66 | when (alignment) { 67 | // Arrow on left side of the bubble 68 | LEFT_TOP, LEFT_CENTER, LEFT_BOTTOM -> { 69 | cornerRadius.topLeft = min( 70 | modifier.arrowTop, 71 | cornerRadius.topLeft 72 | ) 73 | 74 | cornerRadius.bottomLeft = 75 | min(cornerRadius.bottomLeft, (contentRect.height() - modifier.arrowBottom)) 76 | } 77 | 78 | // Arrow on right side of the bubble 79 | RIGHT_TOP, RIGHT_CENTER, RIGHT_BOTTOM -> { 80 | cornerRadius.topRight = min( 81 | modifier.arrowTop, 82 | cornerRadius.topRight 83 | ) 84 | 85 | cornerRadius.bottomRight = 86 | min(cornerRadius.bottomRight, (contentRect.height() - modifier.arrowBottom)) 87 | } 88 | 89 | // Arrow at the bottom of bubble 90 | BOTTOM_LEFT -> { 91 | cornerRadius.bottomLeft = 92 | if (modifier.arrowOffsetY < maxRadius) 0f 93 | else cornerRadius.bottomLeft 94 | } 95 | BOTTOM_RIGHT -> { 96 | cornerRadius.bottomRight = 97 | if (modifier.arrowOffsetY < maxRadius) 0f 98 | else cornerRadius.bottomRight 99 | } 100 | else -> Unit 101 | } 102 | } 103 | 104 | val radii = floatArrayOf( 105 | cornerRadius.topLeft, 106 | cornerRadius.topLeft, 107 | cornerRadius.topRight, 108 | cornerRadius.topRight, 109 | cornerRadius.bottomRight, 110 | cornerRadius.bottomRight, 111 | cornerRadius.bottomLeft, 112 | cornerRadius.bottomLeft 113 | ) 114 | 115 | path.addRoundRect(contentRect, radii, Path.Direction.CW) 116 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 19 | 20 | 25 | 26 | 27 | 28 | 37 | 38 | 44 | 45 | 51 | 52 | 58 | 59 | 60 | 61 | 62 | 63 | 70 | 71 | 77 | 78 | 84 | 85 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 104 | 105 | 111 | 112 | 118 | 119 | 120 | 121 | 122 | 129 | 130 | 136 | 137 | 143 | 144 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 166 | 167 | 172 | 173 | 174 | 175 | 183 | 184 | 189 | 190 | 191 | 192 | 199 | 200 | 205 | 206 | 207 | 215 | 216 | 221 | 222 | 223 | 224 | 233 | 234 | 239 | 240 | 241 | 242 | 251 | 252 | 257 | 258 | 259 | 268 | 269 | 274 | 275 | 276 | 277 | 285 | 286 | 291 | 292 | 293 | 301 | 302 | 307 | 308 | 309 | 317 | 318 | 323 | 324 | 325 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main_bubble_linear_with_attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 20 | 21 | 26 | 27 | 28 | 29 | 38 | 39 | 40 | 41 | 47 | 48 | 55 | 56 | 57 | 58 | 59 | 67 | 68 | 69 | 75 | 76 | 82 | 83 | 84 | 85 | 86 | 87 | 96 | 97 | 103 | 104 | 110 | 111 | 112 | 113 | 114 | 123 | 124 | 130 | 131 | 137 | 138 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 163 | 164 | 169 | 170 | 171 | 172 | 182 | 183 | 188 | 189 | 190 | 191 | 201 | 202 | 207 | 208 | 209 | 220 | 221 | 226 | 227 | 228 | 229 | 241 | 242 | 247 | 248 | 249 | 250 | 264 | 265 | 270 | 271 | 272 | 283 | 284 | 289 | 290 | 291 | 292 | 307 | 308 | 313 | 314 | 315 | 330 | 331 | 336 | 337 | 338 | 353 | 354 | 359 | 360 | 361 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main_curve.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main_with_attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 20 | 21 | 26 | 27 | 28 | 29 | 38 | 39 | 45 | 46 | 52 | 53 | 59 | 60 | 61 | 62 | 63 | 64 | 72 | 73 | 79 | 80 | 86 | 87 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 108 | 109 | 115 | 116 | 122 | 123 | 124 | 125 | 126 | 135 | 136 | 142 | 143 | 149 | 150 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 175 | 176 | 181 | 182 | 183 | 184 | 194 | 195 | 200 | 201 | 202 | 203 | 213 | 214 | 219 | 220 | 221 | 232 | 233 | 238 | 239 | 240 | 241 | 253 | 254 | 259 | 260 | 261 | 262 | 276 | 277 | 282 | 283 | 284 | 295 | 296 | 301 | 302 | 303 | 304 | 319 | 320 | 325 | 326 | 327 | 342 | 343 | 348 | 349 | 350 | 365 | 366 | 371 | 372 | 373 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/main/res/values/attrs_bubble_layout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | BubbleLayout 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /app/src/test/java/com/smarttoolfactory/bubblelayout/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.smarttoolfactory.bubblelayout 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | buildscript { 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | dependencies { 8 | classpath 'com.android.tools.build:gradle:7.0.4' 9 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.30" 10 | 11 | // NOTE: Do not place your application dependencies here; they belong 12 | // in the individual module build.gradle files 13 | } 14 | } 15 | 16 | task clean(type: Delete) { 17 | delete rootProject.buildDir 18 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Mar 05 22:55:23 TRT 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /screenshots/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/screenshots/img1.png -------------------------------------------------------------------------------- /screenshots/img2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartToolFactory/BubbleLayout/e07b642b4ddcc4103fbf82fbca504373c10ed53b/screenshots/img2.png -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 3 | repositories { 4 | google() 5 | mavenCentral() 6 | jcenter() // Warning: this repository is going to shut down soon 7 | } 8 | } 9 | rootProject.name = "BubbleLayout" 10 | include ':app' 11 | --------------------------------------------------------------------------------