├── .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 |
--------------------------------------------------------------------------------