๊ฐ๋ฐํ๊ฒ ๋ ๊ณ๊ธฐ๋ ๋ ๊ฐ์ง ์ด์ ๊ฐ ์์์ต๋๋ค.
๊ฐ์ฅ ํฐ ์ด์ ๋ ๊ธฐํ์&๋์์ด๋๊ป์
๊ธฐ์กด์ ์ฃผ์๊ฐ ๋
ธ์ถ๋๋ ๋ถ๋ถ์
Marker ์์ Bubble ํํ๋ก ๋ณ๊ฒฝ ์์ฒญ์ด์์ต๋๋ค.
๋ ๋ฒ์งธ๋ ๊ธฐ์กด์ Bubble ํํ์ ํ
์คํธ๊ฐ ๊ฐ๋ฐ์ ๋์ด์์๋๋ฐ
๋์ ์ผ๋ก ๋ณํ๋ ํ
์คํธ์ ๋ํ ๋์์ด ์ ๋์ด์์๊ณ
Bubble ํํ๋ฅผ ๊ทธ๋ฆฌ๋ ๋ฐฉ๋ฒ์ด ์ฌ๋ฐ๋ฅด์ง ์์์ต๋๋ค.
(๐ฅฒColumn ๊ณผ Text๋ฅผ ํผํฉํ๊ณ ์ผ๊ฐํ์ ๊ทธ๋ ค ๋ถ์ด๋ ํํ๋ก)
์ ๊ธฐํ์ ๋ํ UI๋ฅผ ๊ฐ๋ฐํ๊ธฐ ์ํด๋ ๋ค์๊ณผ ๊ฐ์ด ๋จ๊ณ๋ณ๋ก ์๊ฐํด ๋ณผ ์ ์์ต๋๋ค.
ํ
์คํธ์ ์ ์ฑ
์ ๋ง๊ฒ ๋์ด์ ๋์ด๊ฐ ์ ํ์ ์ผ๋ก ๊ณ์ฐ๋์ด์ผ ํฉ๋๋ค.
๊ณ์ฐ๋ ๋์ด์ ๋์ด๋ฅผ ๋ฐํ์ผ๋ก
BubbleShape ์ ๋์ด์ ๋์ด๋ฅผ ๊ฒฐ์ ํ ์ ์์ต๋๋ค.
๊ณ์ฐ๋ ๋์ด์ ๋์ด๋ฅผ ๋ฐํ์ผ๋ก
๋ฅ๊ทผ ๋ชจ์๋ฆฌ(arc) ์ path๋ฅผ ์ด์ฉํ BubbleShape ๊ทธ๋ฆฌ๊ธฐ
Marker ์์ ๊ทธ๋ฆฌ๊ธฐ ์ํด์ Marker ์์น๋ฅผ ํ์
ํ๊ณ
ํ์
๋ ์์น๋ฅผ ๋ฐํ์ผ๋ก ์๋ก
BubbleShape ๋์ด + spacing ๊ฐ๊ฒฉ ๋ฒ๋ฆฌ๊ธฐ
์ฐ์ ์ปดํฌ๋ํธ์ ์์ฑ๋ณ๋ก ๊ด๋ฆฌํ ์ ์๋ ํด๋์ค๋ฅผ ๋ง๋ค์์ต๋๋ค.
๐ ํ ์คํธ ๊ด๋ฆฌ
- text: ๋ณด์ฌ์ง ํ ์คํธ
- textStyle: ํ ์คํธ ์คํ์ผ
- textMaxRatio: ํ ์คํธ๊ฐ ์ฐจ์งํ ์ต๋ ๋น์จ
@Immutable data class TextAppearance private constructor( val text: String, val textStyle: TextStyle, @FloatRange(from = 0.0, to = 1.0) val textMaxRatio: Float ) { companion object { @Composable fun setAppearance( text: String, textStyle: TextStyle = MaterialTheme.typography.labelSmall.copy(color = PetnowColor.Grey.Tertiary), textMaxRatio: Float = 0.7f ): TextAppearance { return TextAppearance( text = text, textStyle = textStyle, textMaxRatio = textMaxRatio ) } } }
๐ ๋ฒ๋ธ ๋ชจ์ ๊ด๋ฆฌ
๊ฐ์ ์ ์๋ก ๋ฐ๊ณ ์ด๋ฅผ ํ๋ฉด ๋ฐ๋(density)๋ฅผ ์ด์ฉํ์ฌ float(px)๋ก ์นํ ํฉ๋๋ค.
- padding: ๋ฒ๋ธ ๋ชจ์์ padding
- backgroundColor: ๋ฒ๋ธ ๋ชจ์์ ์ ์ฒด ์ปฌ๋ฌ
- triangleHeight: ๋ฒ๋ธ ๋ชจ์์ ๊ผฌ๋ฆฌ ์ผ๊ฐํ ๋์ด
- triangleWidth: ๋ฒ๋ธ ๋ชจ์์ ๊ผฌ๋ฆฌ ์ผ๊ฐํ ๋์ด
- cornerRadius: ๋ฒ๋ธ ๋ชจ์์ ๋ผ์ด๋ ๊ฐ
- shadowRadius: ๋ฒ๋ธ ๋ชจ์์ ๊ทธ๋ฆผ์ ๊ฐ
- shadowColor: ๋ฒ๋ธ ๋ชจ์์ ๊ทธ๋ฆผ์ ์์
- spacingBetweenAnchorUI: ๋ฒ๋ธ ๋ชจ์์ ์ง์ ํ๋ ์์น ui์์ ๊ฐ๊ฒฉ
@Immutable data class BubbleShape private constructor( val padding: Float, val backGroundColor: Color, val triangleHeight: Float, val triangleWidth: Float, val cornerRadius: Float, val shadowRadius: Float, val shadowColor: Color, val spacingBetweenAnchorUI: Float, ) { companion object { @Composable fun setShape( padding: Int = 12, backGroundColor: Color = Color.White, triangleHeight: Int = 10, triangleWidth: Int = 16, cornerRadius: Int = 8, shadowRadius: Int = 8, spacingBetweenAnchorUI: Int = 4 ): BubbleShape { val density = LocalDensity.current val bubblePaddingPx = with(density) { padding.dp.toPx() } val triangleHeightPx = with(density) { triangleHeight.dp.toPx() } val triangleWidthPx = with(density) { triangleWidth.dp.toPx() } val cornerRadiusPx = with(density) { cornerRadius.dp.toPx() } val shadowRadiusPx = with(density) { shadowRadius.dp.toPx() } val shadowColor = Color.Black.copy(alpha = 0.15f) val spacingPx = with (density) { spacingBetweenAnchorUI.dp.toPx() } return BubbleShape( padding = bubblePaddingPx, backGroundColor = backGroundColor, triangleHeight = triangleHeightPx, triangleWidth = triangleWidthPx, cornerRadius = cornerRadiusPx, shadowRadius = shadowRadiusPx, shadowColor = shadowColor, spacingBetweenAnchorUI = spacingPx ) } } }
์ด๋ป๊ฒ ํ
์คํธ์ ๊ธ์์ ๋ฐ๋ผ ๋์ ์ผ๋ก ๋์ด์ ๋์ด๋ฅผ ๊ณ์ฐํ ์ ์์๊น์
TextMeasure๋ฅผ ์ด์ฉํ๋ฉด ์ด๋ฅผ ํด๊ฒฐํ ์ ์์ต๋๋ค.
private val DefaultCacheSize: Int = 8
/**
* Creates and remembers a [TextMeasurer]. All parameters that are required for [TextMeasurer]
* except [cacheSize] are read from CompositionLocals. Created [TextMeasurer] carries an internal
* [TextLayoutCache] with [cacheSize] capacity. Provide 0 for [cacheSize] to opt-out from internal
* caching behavior.
*
* @param cacheSize Capacity of internal cache inside [TextMeasurer]. Size unit is the number of
* unique text layout inputs that are measured. Value of this parameter highly depends on the
* consumer use case. Provide a cache size that is in line with how many distinct text layouts are
* going to be calculated by this measurer repeatedly. If you are animating font attributes, or any
* other layout affecting input, cache can be skipped because most repeated measure calls would miss
* the cache.
*/
@Composable
fun rememberTextMeasurer(
cacheSize: Int = DefaultCacheSize
): TextMeasurer {
val fontFamilyResolver = LocalFontFamilyResolver.current
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
return remember(fontFamilyResolver, density, layoutDirection, cacheSize) {
TextMeasurer(fontFamilyResolver, density, layoutDirection, cacheSize)
}
}
๐ textMeasurer๋ฅผ ์ด์ฉํด์ BubbleShape width, height ๊ณ์ฐํ๊ธฐ
- textMeasurer๋ฅผ ํตํด measure๋ ๊ฒฐ๊ณผ๋ฅผ ํตํด
width, height์ padding๊ฐ์ ํฉ์ณ BubbleShape width, height ๊ฒฐ๊ณผ ๋์ถ
๋์ด๋ ์์์ ๊ณ์ฐํ bubbleWidth๋ก ์ง์ ํ๊ณ
๋์ด๋ ์์์ ๊ณ์ฐํ bubbleHeight์ ์ผ๊ฐํ์ ๋์ด๋ฅผ ๋ํ์ฌ ์ง์ ํฉ๋๋ค.
Canvas(
modifier = modifier
.width(with(density) { bubbleWidth.toDp() })
.height(with(density) { (bubbleHeight + bubbleShape.triangleHeight).toDp() })
.padding(end = with(density) { bubbleShape.padding.toDp() })
)
compose์์ Paint๋ฅผ ์ฌ์ฉํ์ฌ ์์์ ์ ์ฉํ ๋
โ๐ป๊ทธ๋ฆผ์โ๐ป๋ ์์ง ๋ฏธ์ง์์ด๋ฏ๋ก ๊ธฐ์กด ์๋๋ก์ด๋ ์์คํ
์์ ์ค์ ํด์ผ ํฉ๋๋ค.
val paint = Paint().apply {
this.color = bubbleShape.backGroundColor
asFrameworkPaint().setShadowLayer(
bubbleShape.shadowRadius,
0f,
2f,
bubbleShape.shadowColor.toArgb()
)
}
1๋ฒ ์ฒซ ์์์ radius๋ก ๋ถํฐ ์์ํ๊ฒ ์ค์ (moveTo)
2๋ฒ lineTo๋ก ๋ชจ์๋ฆฌ ์ฒ๋ฆฌ ์ง์ ๊น์ง ์ด๋ (width-radius)
3๋ฒ arcTo ๋ ์ฌ๊ฐํ์ ๊ทธ๋ฆฌ๊ณ ๊ฑฐ๊ธฐ์ ์์ ๊ทธ๋ฆฌ๋ ๋ก์ง
...
6๋ฒ ์ผ๊ฐํ์ ๊ธฐ์ค์ ์ bubbleWidth์ ์ค๊ฐ์ง์ (bubbleWidth/2)
...
10๋ฒ๊น์ง ๊ฐ๋ฉด bubleShape ์ Path๋ฅผ ๊ทธ๋ฆด ์ ์๊ฒ ๋ฉ๋๋ค.
val bubblePath = Path().apply {
moveTo(bubbleShape.cornerRadius, 0f) // 1.
lineTo(
bubbleWidth - bubbleShape.cornerRadius,
0f
) // 2.
arcTo(
rect = Rect(
offset = Offset(bubbleWidth - bubbleShape.cornerRadius * 2, 0f),
size = Size(bubbleShape.cornerRadius * 2, bubbleShape.cornerRadius * 2)
),
startAngleDegrees = -90f,
sweepAngleDegrees = 90f,
forceMoveTo = false
) // 3.
lineTo(
bubbleWidth,
bubbleHeight - bubbleShape.cornerRadius
) // 4.
arcTo(
rect = Rect(
offset = Offset(bubbleWidth - bubbleShape.cornerRadius * 2, bubbleHeight - bubbleShape.cornerRadius * 2),
size = Size(bubbleShape.cornerRadius * 2, bubbleShape.cornerRadius * 2)
),
startAngleDegrees = 0f,
sweepAngleDegrees = 90f,
forceMoveTo = false
) // 5
drawTriangle(
bubbleShape = bubbleShape,
bubbleWidth = bubbleWidth,
bubbleHeight = bubbleHeight
) // 6
lineTo(
bubbleShape.cornerRadius,
bubbleHeight
) // 7
arcTo(
rect = Rect(
offset = Offset(0f, bubbleHeight - bubbleShape.cornerRadius * 2),
size = Size(bubbleShape.cornerRadius * 2, bubbleShape.cornerRadius * 2)
),
startAngleDegrees = 90f,
sweepAngleDegrees = 90f,
forceMoveTo = false
) // 8
lineTo(
0f,
bubbleShape.cornerRadius
) // 9
arcTo(
rect = Rect(
offset = Offset(0f, 0f),
size = Size(bubbleShape.cornerRadius * 2, bubbleShape.cornerRadius * 2)
),
startAngleDegrees = 180f,
sweepAngleDegrees = 90f,
forceMoveTo = false
) // 10
close()
}
private fun Path.drawTriangle(
bubbleShape: BubbleShape,
bubbleWidth: Float,
bubbleHeight: Float
) = apply {
lineTo(
(bubbleWidth + bubbleShape.triangleWidth) / 2,
bubbleHeight
)
lineTo(
bubbleWidth / 2,
bubbleHeight + bubbleShape.triangleHeight
)
lineTo(
(bubbleWidth - bubbleShape.triangleWidth) / 2,
bubbleHeight
)
}
์ค๋นํ paint, path๋ฅผ ๋ฐํ์ผ๋ก drawIntoCanvas ํจ์๋ก canvas์ path๋ฅผ ๊ทธ๋ฆฝ๋๋ค.
์ถ๊ฐ๋ก ํ
์คํธ ์ญ์ ์์ textMeasurer์ textAppearance๋ก ๊ฐ์ด ๊ทธ๋ ค์ค๋๋ค.
Canvas(
modifier = modifier
.width(with(density) { bubbleWidth.toDp() })
.height(with(density) { (bubbleHeight + bubbleShape.triangleHeight).toDp() })
.padding(end = with(density) { bubbleShape.padding.toDp() })
) {
val paint = Paint().apply {
this.color = bubbleShape.backGroundColor
asFrameworkPaint().setShadowLayer(
bubbleShape.shadowRadius,
0f,
2f,
bubbleShape.shadowColor.toArgb()
)
}
val bubblePath = Path().apply {
// ์ ์ฝ๋ ์ฐธ์กฐ
...
}
drawIntoCanvas { canvas ->
canvas.drawPath(bubblePath, paint)
}
drawText(
textMeasurer = textMeasurer,
text = textAppearance.text,
style = textStyle.copy(color = PetnowColor.Grey.Tertiary),
topLeft = Offset(bubbleShape.padding, bubbleShape.padding)
)
}
markerUI์์ BubbleShape๊ฐ ์์นํด์ผ ํ๋ฏ๋ก
markUI โ๐ป์์น๋ฅผ ๊ฐ์ ธ์์ผโ๐ป ํฉ๋๋ค.
์ ๋ .onGloballyPositioned { } ๋ฅผ ํ์ฉํ์ฌ anchorPosition์ ์์น๋ฅผ ํ ๋น ํ์ต๋๋ค.
์ดํ ๊ฐ์ ธ์จ anchorPosition์ผ๋ก y ์ขํ ์์
bubbleShape ๋์ด์ ๊ธฐํ๋ ๋น ๊ณต๊ฐ๋งํผ์
verticalSpacing์ ํ ๋นํ๊ณ
.graphicsLayer { this.translationY = verticalSpacing } ๋ก ์์น๋ฅผ ์ค์ ํ์ต๋๋ค.
@Composable
fun AddressBubbleBox(
modifier: Modifier = Modifier,
bubbleShape: BubbleShape = BubbleShape.setShape(),
textAppearance: TextAppearance,
markerUI: @Composable () -> Unit
) {
var anchorPosition by remember { mutableStateOf(Offset.Zero) }
var bubbleHeight by remember {
mutableFloatStateOf(0f)
}
val verticalSpacing = anchorPosition.y - bubbleHeight - bubbleShape.triangleHeight - bubbleShape.spacingBetweenAnchorUI
Box(modifier) {
AnimatedVisibility(textAppearance.text.isNotEmpty()) {
BubbleBox(
modifier = Modifier.graphicsLayer {
this.translationY = verticalSpacing
},
textAppearance = textAppearance,
bubbleShape = bubbleShape,
onChangeBubbleHeight = {
bubbleHeight = it
}
)
}
Box(
modifier = Modifier
.align(Alignment.Center)
.onGloballyPositioned { coordinate ->
anchorPosition = coordinate.positionInParent()
}
) {
markerUI()
}
}
}
์ฌ๊ธฐ๊น์ง ๋ชจ๋ ์ค์ ์ ํ๊ณ ์๋ฃํ ํ๋ฉด์ ์๋์ ๊ฐ์ด ํ์ธํ ์ ์์ต๋๋ค.
ํด๋น ๊ธฐํ์ ๋ํด ๊ฐ๋ฐ ํ๋ฉด์
canvas์ ์ขํ๊ณ์ ๋ํด์ ์๊ฒ ๋๊ณ
shadow ์ค์ ์ ๊ธฐ์กด ์ปดํฌ์ฆ์์ ์ ๋์ ๋ ๊ฑฐ์ ์์คํ
์
์ด์ฉํด์ผ ํ๋ค๋ ๊ฒ์ ๋ฐฐ์ ์ต๋๋ค.
์์ ๊ฐ์ UI๋ ๊ฝค ๋ง์ด ์ด์ฉ๋ ๊ฒ ๊ฐ์์
์ ๋ฆฌ ๋ฐ ๊ณต์ ํ๋ ์๊ฐ์ ๊ฐ์ก์ต๋๋ค.
(์ด์ ์ ๊ฐ๋ฐํ๋ ๋ถ๋ถ์ธ๋ฐ ์ ๋ฆฌ๋ฅผ ์ด์ ์์ผ ํ๋ ๋ถ๋ถ์
๋๋ค.๐ฅฒ)
์ด๋ง ํฌ์คํ ์ ๋ง์น๊ฒ ์ต๋๋ค๐๐ปโโ๏ธ