Skip to content

Commit b3b4a02

Browse files
committed
Fix outline offset geometry
Expand rounded outline radii by the offset before applying the stroke width so focus rings keep their expected shape when drawn outside a component.
1 parent 0013151 commit b3b4a02

3 files changed

Lines changed: 164 additions & 59 deletions

File tree

composeunstyled-outline/build.gradle.kts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ kotlin {
8787
implementation(libs.compose.foundation)
8888
}
8989
}
90+
91+
commonTest.dependencies {
92+
implementation(kotlin("test"))
93+
implementation(libs.assertk)
94+
}
9095
}
9196
}
9297

composeunstyled-outline/src/commonMain/kotlin/com/composeunstyled/Outline.kt

Lines changed: 75 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -49,81 +49,97 @@ fun Modifier.outline(
4949
}
5050

5151
is Outline.Rectangle -> {
52-
val inset = -offset.toPx()
52+
val geometry = calculateOutlineRectGeometry(
53+
rect = Rect(0f, 0f, size.width, size.height),
54+
strokeWidth = strokeWidth,
55+
offset = offset.toPx(),
56+
)
5357

5458
val path = Path().apply {
55-
addRect(
56-
Rect(
57-
left = inset - strokeWidth,
58-
top = inset - strokeWidth,
59-
right = size.width - inset + strokeWidth,
60-
bottom = size.height - inset + strokeWidth,
61-
),
62-
)
59+
addRect(geometry.outer)
6360
fillType = PathFillType.EvenOdd
64-
addRect(
65-
Rect(
66-
left = inset,
67-
top = inset,
68-
right = size.width - inset,
69-
bottom = size.height - inset,
70-
),
71-
)
61+
addRect(geometry.inner)
7262
}
7363

7464
drawPath(path, color)
7565
}
7666

7767
is Outline.Rounded -> {
78-
val inset = -offset.toPx()
79-
val roundRect = outline.roundRect
80-
81-
val topLeftRadius = roundRect.topLeftCornerRadius.x
82-
val topRightRadius = roundRect.topRightCornerRadius.x
83-
val bottomRightRadius = roundRect.bottomRightCornerRadius.x
84-
val bottomLeftRadius = roundRect.bottomLeftCornerRadius.y
85-
86-
val topLeftOutlineRadius = topLeftRadius + strokeWidth
87-
val topRightOutlineRadius = topRightRadius + strokeWidth
88-
val bottomRightOutlineRadius = bottomRightRadius + strokeWidth
89-
val bottomLeftOutlineRadius = bottomLeftRadius + strokeWidth
68+
val geometry = calculateOutlineRoundRectGeometry(
69+
roundRect = outline.roundRect,
70+
strokeWidth = strokeWidth,
71+
offset = offset.toPx(),
72+
)
9073

9174
val path = Path().apply {
92-
addRoundRect(
93-
RoundRect(
94-
left = inset - strokeWidth,
95-
top = inset - strokeWidth,
96-
right = size.width - inset + strokeWidth,
97-
bottom = size.height - inset + strokeWidth,
98-
topLeftCornerRadius = CornerRadius(topLeftOutlineRadius, topLeftOutlineRadius),
99-
topRightCornerRadius = CornerRadius(topRightOutlineRadius, topRightOutlineRadius),
100-
bottomRightCornerRadius = CornerRadius(
101-
bottomRightOutlineRadius,
102-
bottomRightOutlineRadius,
103-
),
104-
bottomLeftCornerRadius = CornerRadius(
105-
bottomLeftOutlineRadius,
106-
bottomLeftOutlineRadius,
107-
),
108-
),
109-
)
75+
addRoundRect(geometry.outer)
11076
fillType = PathFillType.EvenOdd
111-
addRoundRect(
112-
RoundRect(
113-
left = inset,
114-
top = inset,
115-
right = size.width - inset,
116-
bottom = size.height - inset,
117-
topLeftCornerRadius = CornerRadius(topLeftRadius, topLeftRadius),
118-
topRightCornerRadius = CornerRadius(topRightRadius, topRightRadius),
119-
bottomRightCornerRadius = CornerRadius(bottomRightRadius, bottomRightRadius),
120-
bottomLeftCornerRadius = CornerRadius(bottomLeftRadius, bottomLeftRadius),
121-
),
122-
)
77+
addRoundRect(geometry.inner)
12378
}
12479

12580
drawPath(path, color)
12681
}
12782
}
12883
}
12984
}
85+
86+
internal data class OutlineRectGeometry(
87+
val inner: Rect,
88+
val outer: Rect,
89+
)
90+
91+
internal data class OutlineRoundRectGeometry(
92+
val inner: RoundRect,
93+
val outer: RoundRect,
94+
)
95+
96+
internal fun calculateOutlineRectGeometry(
97+
rect: Rect,
98+
strokeWidth: Float,
99+
offset: Float,
100+
): OutlineRectGeometry {
101+
return OutlineRectGeometry(
102+
inner = rect.inflate(offset),
103+
outer = rect.inflate(offset + strokeWidth),
104+
)
105+
}
106+
107+
internal fun calculateOutlineRoundRectGeometry(
108+
roundRect: RoundRect,
109+
strokeWidth: Float,
110+
offset: Float,
111+
): OutlineRoundRectGeometry {
112+
return OutlineRoundRectGeometry(
113+
inner = roundRect.inflate(offset),
114+
outer = roundRect.inflate(offset + strokeWidth),
115+
)
116+
}
117+
118+
private fun Rect.inflate(amount: Float): Rect {
119+
return Rect(
120+
left = left - amount,
121+
top = top - amount,
122+
right = right + amount,
123+
bottom = bottom + amount,
124+
)
125+
}
126+
127+
private fun RoundRect.inflate(amount: Float): RoundRect {
128+
return RoundRect(
129+
left = left - amount,
130+
top = top - amount,
131+
right = right + amount,
132+
bottom = bottom + amount,
133+
topLeftCornerRadius = topLeftCornerRadius.inflate(amount),
134+
topRightCornerRadius = topRightCornerRadius.inflate(amount),
135+
bottomRightCornerRadius = bottomRightCornerRadius.inflate(amount),
136+
bottomLeftCornerRadius = bottomLeftCornerRadius.inflate(amount),
137+
)
138+
}
139+
140+
private fun CornerRadius.inflate(amount: Float): CornerRadius {
141+
return CornerRadius(
142+
x = (x + amount).coerceAtLeast(0f),
143+
y = (y + amount).coerceAtLeast(0f),
144+
)
145+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright (c) 2026 Composable Horizons
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal
6+
* in the Software without restriction, including without limitation the rights
7+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8+
* copies of the Software, and to permit persons to whom the Software is
9+
* furnished to do so, subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20+
* SOFTWARE.
21+
*/
22+
package com.composeunstyled
23+
24+
import androidx.compose.ui.geometry.CornerRadius
25+
import androidx.compose.ui.geometry.Rect
26+
import androidx.compose.ui.geometry.RoundRect
27+
import assertk.assertThat
28+
import assertk.assertions.isEqualTo
29+
import kotlin.test.Test
30+
31+
class OutlineTest {
32+
@Test
33+
fun rectangleOffsetExpandsInnerAndOuterBounds() {
34+
val geometry = calculateOutlineRectGeometry(
35+
rect = Rect(left = 0f, top = 0f, right = 100f, bottom = 40f),
36+
strokeWidth = 2f,
37+
offset = 4f,
38+
)
39+
40+
assertThat(geometry.inner).isEqualTo(Rect(left = -4f, top = -4f, right = 104f, bottom = 44f))
41+
assertThat(geometry.outer).isEqualTo(Rect(left = -6f, top = -6f, right = 106f, bottom = 46f))
42+
}
43+
44+
@Test
45+
fun roundedOffsetExpandsCornerRadii() {
46+
val geometry = calculateOutlineRoundRectGeometry(
47+
roundRect = RoundRect(
48+
left = 0f,
49+
top = 0f,
50+
right = 100f,
51+
bottom = 40f,
52+
cornerRadius = CornerRadius(8f, 8f),
53+
),
54+
strokeWidth = 2f,
55+
offset = 4f,
56+
)
57+
58+
assertThat(geometry.inner.topLeftCornerRadius).isEqualTo(CornerRadius(12f, 12f))
59+
assertThat(geometry.outer.topLeftCornerRadius).isEqualTo(CornerRadius(14f, 14f))
60+
}
61+
62+
@Test
63+
fun roundedOffsetPreservesAsymmetricCornerRadii() {
64+
val geometry = calculateOutlineRoundRectGeometry(
65+
roundRect = RoundRect(
66+
left = 0f,
67+
top = 0f,
68+
right = 100f,
69+
bottom = 40f,
70+
topLeftCornerRadius = CornerRadius(8f, 4f),
71+
topRightCornerRadius = CornerRadius(10f, 6f),
72+
bottomRightCornerRadius = CornerRadius(12f, 8f),
73+
bottomLeftCornerRadius = CornerRadius(14f, 10f),
74+
),
75+
strokeWidth = 2f,
76+
offset = 4f,
77+
)
78+
79+
assertThat(geometry.inner.topLeftCornerRadius).isEqualTo(CornerRadius(12f, 8f))
80+
assertThat(geometry.outer.topLeftCornerRadius).isEqualTo(CornerRadius(14f, 10f))
81+
assertThat(geometry.inner.bottomRightCornerRadius).isEqualTo(CornerRadius(16f, 12f))
82+
assertThat(geometry.outer.bottomRightCornerRadius).isEqualTo(CornerRadius(18f, 14f))
83+
}
84+
}

0 commit comments

Comments
 (0)