Skip to content

Commit 1d19a62

Browse files
committed
Fix Fit.LAYOUT artboard not resizing to match view dimensions
For Fit.LAYOUT, the Yoga layout engine recalculates artboard dimensions during advance(), overwriting the size set by resizeArtboard(). Since the requireArtboardResize flag was already consumed, the artboard stayed at the wrong width on subsequent frames. Fix: always call resizeArtboard() before draw when fit is LAYOUT, and set requireArtboardResize in onSurfaceTextureSizeChanged/Available. Fixes #446
1 parent fc889d0 commit 1d19a62

6 files changed

Lines changed: 451 additions & 4 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package app.rive.runtime.example
2+
3+
import android.widget.FrameLayout
4+
import androidx.test.core.app.ActivityScenario
5+
import androidx.test.ext.junit.runners.AndroidJUnit4
6+
import app.rive.runtime.kotlin.RiveAnimationView
7+
import app.rive.runtime.kotlin.core.File
8+
import app.rive.runtime.kotlin.core.Fit
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Assert.assertNotEquals
11+
import org.junit.Assert.assertNotNull
12+
import org.junit.Assert.assertTrue
13+
import org.junit.Test
14+
import org.junit.runner.RunWith
15+
import java.util.concurrent.CountDownLatch
16+
import java.util.concurrent.TimeUnit
17+
import kotlin.time.Duration.Companion.milliseconds
18+
19+
/**
20+
* Reproducer for the Fit.LAYOUT race condition:
21+
*
22+
* When setRiveFile(fit = Fit.LAYOUT) is called on a 0×0 view (before the
23+
* first measure/layout pass), the artboard sometimes stays at its intrinsic
24+
* size instead of resizing to match the view.
25+
*
26+
* This happens because setupScene() nulls activeArtboard then sets
27+
* requireArtboardResize=true, and the render thread can consume that flag
28+
* while activeArtboard is still null.
29+
*/
30+
@RunWith(AndroidJUnit4::class)
31+
class FitLayoutReproTest {
32+
33+
/**
34+
* Programmatically add a RiveAnimationView and call setRiveFile with
35+
* Fit.LAYOUT before the view has been measured. Verify the artboard
36+
* resizes to the view size (not stuck at intrinsic size).
37+
*/
38+
@Test
39+
fun fitLayoutResizesWhenSetBeforeMeasure() {
40+
val activityScenario = ActivityScenario.launch(EmptyActivity::class.java)
41+
lateinit var riveView: RiveAnimationView
42+
val laidOutLatch = CountDownLatch(1)
43+
44+
val viewWidthPx = 800
45+
val viewHeightPx = 400
46+
47+
activityScenario.onActivity { activity ->
48+
val riveBytes = activity.resources
49+
.openRawResource(R.raw.layout_test)
50+
.readBytes()
51+
val riveFile = File(riveBytes)
52+
53+
riveView = RiveAnimationView(activity)
54+
riveView.layoutParams = FrameLayout.LayoutParams(viewWidthPx, viewHeightPx)
55+
56+
// Add to container — triggers measure/layout asynchronously
57+
activity.container.addView(riveView)
58+
59+
// Call setRiveFile immediately, before the view has been measured (still 0×0)
60+
riveView.setRiveFile(
61+
riveFile,
62+
fit = Fit.LAYOUT,
63+
autoplay = true,
64+
)
65+
66+
riveView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
67+
laidOutLatch.countDown()
68+
}
69+
}
70+
71+
// Wait for the view to be laid out
72+
assertTrue(
73+
"Timed out waiting for RiveAnimationView layout",
74+
laidOutLatch.await(3, TimeUnit.SECONDS)
75+
)
76+
77+
// Give the render thread a few frames to process the resize
78+
Thread.sleep(500)
79+
80+
activityScenario.onActivity {
81+
val artboard = riveView.controller.activeArtboard
82+
assertNotNull("activeArtboard should not be null", artboard)
83+
84+
val density = riveView.resources.displayMetrics.density
85+
val expectedWidth = viewWidthPx / density
86+
val expectedHeight = viewHeightPx / density
87+
88+
// The artboard should have been resized to match the view (in dp).
89+
// If the bug is present, the artboard stays at intrinsic size (e.g. 500×500 for layout_test).
90+
assertEquals(
91+
"Artboard width should match view width / density",
92+
expectedWidth,
93+
artboard!!.width,
94+
1.0f
95+
)
96+
assertEquals(
97+
"Artboard height should match view height / density",
98+
expectedHeight,
99+
artboard.height,
100+
1.0f
101+
)
102+
}
103+
104+
activityScenario.close()
105+
}
106+
107+
/**
108+
* Run the same test multiple times to catch the intermittent nature.
109+
* The race condition reportedly has ~50% repro rate per process restart.
110+
* Within the same process the outcome is usually consistent, but running
111+
* multiple iterations increases confidence.
112+
*/
113+
@Test
114+
fun fitLayoutResizesRepeated() {
115+
repeat(5) { iteration ->
116+
val activityScenario = ActivityScenario.launch(EmptyActivity::class.java)
117+
lateinit var riveView: RiveAnimationView
118+
val laidOutLatch = CountDownLatch(1)
119+
120+
val viewWidthPx = 800
121+
val viewHeightPx = 400
122+
123+
activityScenario.onActivity { activity ->
124+
val riveBytes = activity.resources
125+
.openRawResource(R.raw.layout_test)
126+
.readBytes()
127+
val riveFile = File(riveBytes)
128+
129+
riveView = RiveAnimationView(activity)
130+
riveView.layoutParams = FrameLayout.LayoutParams(viewWidthPx, viewHeightPx)
131+
activity.container.addView(riveView)
132+
133+
riveView.setRiveFile(
134+
riveFile,
135+
fit = Fit.LAYOUT,
136+
autoplay = true,
137+
)
138+
139+
riveView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
140+
laidOutLatch.countDown()
141+
}
142+
}
143+
144+
assertTrue(
145+
"Iteration $iteration: timed out waiting for layout",
146+
laidOutLatch.await(3, TimeUnit.SECONDS)
147+
)
148+
Thread.sleep(500)
149+
150+
activityScenario.onActivity {
151+
val artboard = riveView.controller.activeArtboard
152+
assertNotNull("Iteration $iteration: artboard null", artboard)
153+
154+
val density = riveView.resources.displayMetrics.density
155+
val expectedWidth = viewWidthPx / density
156+
val expectedHeight = viewHeightPx / density
157+
158+
assertEquals(
159+
"Iteration $iteration: artboard width mismatch",
160+
expectedWidth,
161+
artboard!!.width,
162+
1.0f
163+
)
164+
assertEquals(
165+
"Iteration $iteration: artboard height mismatch",
166+
expectedHeight,
167+
artboard.height,
168+
1.0f
169+
)
170+
}
171+
172+
activityScenario.close()
173+
}
174+
}
175+
}

app/src/main/AndroidManifest.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@
185185
<activity
186186
android:name=".EmptyActivity"
187187
android:exported="true" />
188+
<activity
189+
android:name=".FitLayoutReproActivity"
190+
android:exported="true" />
188191
</application>
189192

190193
</manifest>
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package app.rive.runtime.example
2+
3+
import android.graphics.Color
4+
import android.graphics.drawable.GradientDrawable
5+
import android.os.Bundle
6+
import android.util.Log
7+
import android.view.Gravity
8+
import android.view.View
9+
import android.widget.FrameLayout
10+
import android.widget.LinearLayout
11+
import android.widget.TextView
12+
import androidx.activity.ComponentActivity
13+
import app.rive.runtime.kotlin.RiveAnimationView
14+
import app.rive.runtime.kotlin.core.File
15+
import app.rive.runtime.kotlin.core.Fit
16+
17+
/**
18+
* Reproducer for the Fit.LAYOUT race condition.
19+
*
20+
* Launch via: adb shell am start -n app.rive.runtime.example/.FitLayoutReproActivity
21+
* Then force stop and relaunch repeatedly to observe intermittent failure.
22+
*/
23+
class FitLayoutReproActivity : ComponentActivity() {
24+
companion object {
25+
private const val TAG = "FitLayoutRepro"
26+
}
27+
28+
override fun onCreate(savedInstanceState: Bundle?) {
29+
super.onCreate(savedInstanceState)
30+
31+
val density = resources.displayMetrics.density
32+
33+
val root = LinearLayout(this).apply {
34+
orientation = LinearLayout.VERTICAL
35+
layoutParams = LinearLayout.LayoutParams(
36+
LinearLayout.LayoutParams.MATCH_PARENT,
37+
LinearLayout.LayoutParams.MATCH_PARENT
38+
)
39+
setBackgroundColor(Color.parseColor("#1a1a2e"))
40+
setPadding(0, (48 * density).toInt(), 0, 0)
41+
}
42+
43+
val statusText = TextView(this).apply {
44+
textSize = 16f
45+
setTextColor(Color.WHITE)
46+
setBackgroundColor(Color.argb(180, 0, 0, 0))
47+
setPadding(24, 20, 24, 20)
48+
text = "Loading…"
49+
gravity = Gravity.CENTER
50+
}
51+
52+
val riveBytes = resources.openRawResource(R.raw.layout_test).readBytes()
53+
val riveFile = File(riveBytes)
54+
55+
val border = GradientDrawable().apply {
56+
setStroke((2 * density).toInt(), Color.parseColor("#666666"))
57+
setColor(Color.TRANSPARENT)
58+
}
59+
60+
val riveContainer = FrameLayout(this).apply {
61+
foreground = border
62+
}
63+
64+
val riveView = RiveAnimationView(this)
65+
riveView.layoutParams = FrameLayout.LayoutParams(
66+
FrameLayout.LayoutParams.MATCH_PARENT,
67+
(200 * density).toInt()
68+
)
69+
riveContainer.addView(riveView)
70+
71+
// Call setRiveFile IMMEDIATELY, before the view has been measured (still 0×0)
72+
riveView.setRiveFile(
73+
riveFile,
74+
fit = Fit.LAYOUT,
75+
autoplay = true,
76+
)
77+
78+
root.addView(statusText, LinearLayout.LayoutParams(
79+
LinearLayout.LayoutParams.MATCH_PARENT,
80+
LinearLayout.LayoutParams.WRAP_CONTENT
81+
))
82+
83+
root.addView(riveContainer, LinearLayout.LayoutParams(
84+
LinearLayout.LayoutParams.MATCH_PARENT,
85+
LinearLayout.LayoutParams.WRAP_CONTENT
86+
))
87+
88+
// Visual comparison: two bars showing expected vs actual artboard width
89+
val barContainer = LinearLayout(this).apply {
90+
orientation = LinearLayout.VERTICAL
91+
setPadding((16 * density).toInt(), (24 * density).toInt(), (16 * density).toInt(), 0)
92+
}
93+
94+
val expectedLabel = TextView(this).apply {
95+
text = "Expected artboard width (= view width):"
96+
textSize = 12f
97+
setTextColor(Color.parseColor("#aaaaaa"))
98+
}
99+
barContainer.addView(expectedLabel)
100+
101+
val expectedBar = View(this).apply {
102+
setBackgroundColor(Color.parseColor("#2e7d32"))
103+
}
104+
barContainer.addView(expectedBar, LinearLayout.LayoutParams(0, (24 * density).toInt()).apply {
105+
topMargin = (4 * density).toInt()
106+
})
107+
108+
val actualLabel = TextView(this).apply {
109+
text = "Actual artboard width:"
110+
textSize = 12f
111+
setTextColor(Color.parseColor("#aaaaaa"))
112+
setPadding(0, (12 * density).toInt(), 0, 0)
113+
}
114+
barContainer.addView(actualLabel)
115+
116+
val actualBar = View(this).apply {
117+
setBackgroundColor(Color.parseColor("#c62828"))
118+
}
119+
barContainer.addView(actualBar, LinearLayout.LayoutParams(0, (24 * density).toInt()).apply {
120+
topMargin = (4 * density).toInt()
121+
})
122+
123+
val ratioText = TextView(this).apply {
124+
textSize = 13f
125+
setTextColor(Color.WHITE)
126+
setPadding(0, (12 * density).toInt(), 0, 0)
127+
gravity = Gravity.CENTER
128+
}
129+
barContainer.addView(ratioText)
130+
131+
root.addView(barContainer, LinearLayout.LayoutParams(
132+
LinearLayout.LayoutParams.MATCH_PARENT,
133+
LinearLayout.LayoutParams.WRAP_CONTENT
134+
))
135+
136+
val infoText = TextView(this).apply {
137+
textSize = 11f
138+
setTextColor(Color.parseColor("#666666"))
139+
setPadding(24, (24 * density).toInt(), 24, 16)
140+
text = "layout_test.riv | Fit.LAYOUT\nForce stop & relaunch to test"
141+
gravity = Gravity.CENTER
142+
}
143+
root.addView(infoText)
144+
145+
setContentView(root)
146+
147+
riveView.addOnLayoutChangeListener { _, left, top, right, bottom, _, _, _, _ ->
148+
val viewWidthPx = right - left
149+
150+
riveView.postDelayed({
151+
val artboard = riveView.controller.activeArtboard ?: return@postDelayed
152+
val abW = artboard.width
153+
val abH = artboard.height
154+
val expectedW = viewWidthPx / density
155+
val expectedH = (bottom - top) / density
156+
val ok = kotlin.math.abs(abW - expectedW) < 2f
157+
158+
val msg = if (ok) {
159+
"✓ Artboard %.0f dp = View %.0f dp".format(abW, expectedW)
160+
} else {
161+
"✗ Artboard %.0f dp ≠ View %.0f dp (%.1fx too wide!)".format(
162+
abW, expectedW, abW / expectedW)
163+
}
164+
Log.d(TAG, msg)
165+
statusText.text = msg
166+
statusText.setBackgroundColor(
167+
if (ok) Color.parseColor("#2e7d32") else Color.parseColor("#c62828")
168+
)
169+
170+
// Scale both bars relative to the container width
171+
val containerWidth = barContainer.width - barContainer.paddingLeft - barContainer.paddingRight
172+
val maxArtboard = maxOf(abW, expectedW)
173+
174+
val expectedBarWidth = (containerWidth * (expectedW / maxArtboard)).toInt()
175+
val actualBarWidth = (containerWidth * (abW / maxArtboard)).toInt()
176+
177+
expectedBar.layoutParams = expectedBar.layoutParams.apply { width = expectedBarWidth }
178+
actualBar.layoutParams = actualBar.layoutParams.apply { width = actualBarWidth }
179+
expectedBar.requestLayout()
180+
actualBar.requestLayout()
181+
182+
if (ok) {
183+
actualBar.setBackgroundColor(Color.parseColor("#2e7d32"))
184+
ratioText.text = "Widths match ✓"
185+
ratioText.setTextColor(Color.parseColor("#4caf50"))
186+
} else {
187+
actualBar.setBackgroundColor(Color.parseColor("#c62828"))
188+
ratioText.text = "Artboard is %.1f× wider than the view!".format(abW / expectedW)
189+
ratioText.setTextColor(Color.parseColor("#ef5350"))
190+
}
191+
}, 500)
192+
}
193+
}
194+
}

0 commit comments

Comments
 (0)