-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Fix issue with nested typed/primitive preview params (#290)
* [WIP] Fix issue with doubly typed preview params * Add primitive param * Update to use cleaner invoking and handle primitives * Lint and cleanups * rename and lint
- Loading branch information
Showing
4 changed files
with
343 additions
and
57 deletions.
There are no files selected for viewing
76 changes: 76 additions & 0 deletions
76
snapshots/sample/app/src/main/kotlin/com/emergetools/snapshots/sample/ui/IconGroup.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
@file:Suppress("MagicNumber") | ||
|
||
package com.emergetools.snapshots.sample.ui | ||
|
||
import androidx.annotation.IntRange | ||
import androidx.compose.foundation.background | ||
import androidx.compose.foundation.border | ||
import androidx.compose.foundation.layout.Arrangement | ||
import androidx.compose.foundation.layout.Box | ||
import androidx.compose.foundation.layout.Row | ||
import androidx.compose.foundation.layout.size | ||
import androidx.compose.foundation.shape.CircleShape | ||
import androidx.compose.material3.Icon | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.ui.Alignment | ||
import androidx.compose.ui.Modifier | ||
import androidx.compose.ui.draw.clip | ||
import androidx.compose.ui.graphics.Color | ||
import androidx.compose.ui.res.painterResource | ||
import androidx.compose.ui.tooling.preview.Preview | ||
import androidx.compose.ui.tooling.preview.PreviewParameter | ||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider | ||
import androidx.compose.ui.unit.dp | ||
import androidx.compose.ui.unit.times | ||
import com.emergetools.snapshots.sample.ui.theme.SnapshotsSampleTheme | ||
|
||
private class CompletedIconPreviewProvider : PreviewParameterProvider<Int> { | ||
override val values: Sequence<Int> | ||
get() = sequenceOf(1, 2, 3, 4, 5) | ||
} | ||
|
||
@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) | ||
@Composable | ||
fun CompletedIconPilePreview( | ||
@PreviewParameter(CompletedIconPreviewProvider::class) completedWorkouts: Int, | ||
) { | ||
SnapshotsSampleTheme { | ||
IconGroup( | ||
completedCount = completedWorkouts, | ||
) | ||
} | ||
} | ||
|
||
@Composable | ||
fun IconGroup( | ||
@IntRange(from = 1) completedCount: Int, | ||
) { | ||
// Display up to 9 icons | ||
val resultCompletedCount = completedCount.coerceAtMost(9) | ||
val iconSize = 48.dp | ||
val borderSize = 2.dp | ||
val boxSize = iconSize + borderSize | ||
val overlapSize = -1 * 20.dp | ||
|
||
Row( | ||
verticalAlignment = Alignment.CenterVertically, | ||
horizontalArrangement = Arrangement.spacedBy(overlapSize), | ||
) { | ||
repeat(resultCompletedCount) { | ||
Box( | ||
modifier = Modifier | ||
.clip(CircleShape) | ||
.background(Color.Blue) | ||
.border(borderSize, Color.White, CircleShape) | ||
.size(boxSize), | ||
contentAlignment = Alignment.Center, | ||
) { | ||
Icon( | ||
painter = painterResource(com.emergetools.snapshots.sample.R.drawable.ic_launcher_foreground), | ||
contentDescription = null, | ||
modifier = Modifier.size(iconSize), | ||
) | ||
} | ||
} | ||
} | ||
} |
109 changes: 109 additions & 0 deletions
109
snapshots/sample/app/src/main/kotlin/com/emergetools/snapshots/sample/ui/MultiUserRow.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
package com.emergetools.snapshots.sample.ui | ||
|
||
import android.content.res.Configuration | ||
import androidx.compose.foundation.background | ||
import androidx.compose.foundation.layout.Arrangement | ||
import androidx.compose.foundation.layout.Column | ||
import androidx.compose.foundation.layout.Row | ||
import androidx.compose.foundation.layout.Spacer | ||
import androidx.compose.foundation.layout.fillMaxSize | ||
import androidx.compose.foundation.layout.fillMaxWidth | ||
import androidx.compose.foundation.layout.padding | ||
import androidx.compose.foundation.layout.size | ||
import androidx.compose.foundation.layout.width | ||
import androidx.compose.foundation.shape.CircleShape | ||
import androidx.compose.material3.MaterialTheme | ||
import androidx.compose.material3.Surface | ||
import androidx.compose.material3.Text | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.ui.Alignment | ||
import androidx.compose.ui.Modifier | ||
import androidx.compose.ui.draw.clip | ||
import androidx.compose.ui.graphics.Color | ||
import androidx.compose.ui.text.style.TextAlign | ||
import androidx.compose.ui.tooling.preview.Preview | ||
import androidx.compose.ui.tooling.preview.PreviewParameter | ||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider | ||
import androidx.compose.ui.unit.dp | ||
import com.emergetools.snapshots.sample.ui.theme.SnapshotsSampleTheme | ||
|
||
@Composable | ||
fun MultiUserRow(users: List<User>) { | ||
Column { | ||
users.forEach { user -> | ||
Row( | ||
modifier = Modifier | ||
.fillMaxWidth() | ||
.padding(16.dp), | ||
verticalAlignment = Alignment.CenterVertically | ||
) { | ||
// Profile picture | ||
Surface( | ||
modifier = Modifier | ||
.size(40.dp) | ||
.clip(CircleShape) | ||
.background(Color.Cyan), | ||
color = Color.Transparent | ||
) { | ||
// Center content vertically and horizontally | ||
Column( | ||
modifier = Modifier.fillMaxSize(), | ||
verticalArrangement = Arrangement.Center, | ||
horizontalAlignment = Alignment.CenterHorizontally | ||
) { | ||
Text( | ||
text = user.name.firstOrNull()?.uppercase() ?: "", | ||
style = MaterialTheme.typography.bodyLarge, | ||
textAlign = TextAlign.Center | ||
) | ||
} | ||
} | ||
|
||
Spacer(modifier = Modifier.width(16.dp)) | ||
|
||
// User details | ||
Column { | ||
Text(text = user.name, style = MaterialTheme.typography.bodyMedium) | ||
Text(text = user.email, style = MaterialTheme.typography.bodySmall, color = Color.Gray) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
@Preview(showBackground = true) | ||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) | ||
@Composable | ||
fun MultiUserRowPreview( | ||
@PreviewParameter(MultiUserRowPreviewProvider::class) users: List<User>, | ||
) { | ||
SnapshotsSampleTheme { | ||
MultiUserRow(users = users) | ||
} | ||
} | ||
|
||
@Preview(showBackground = true) | ||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) | ||
@Composable | ||
fun MultiUserRowPreviewWithLimit( | ||
@PreviewParameter(MultiUserRowPreviewProvider::class, limit = 2) users: List<User>, | ||
) { | ||
SnapshotsSampleTheme { | ||
MultiUserRow(users = users) | ||
} | ||
} | ||
|
||
class MultiUserRowPreviewProvider : PreviewParameterProvider<List<User>> { | ||
override val values: Sequence<List<User>> = sequenceOf( | ||
listOf(User(name = "Ryan", email = "[email protected]")), | ||
listOf( | ||
User(name = "Ryan", email = "[email protected]"), | ||
User(name = "Trevor", email = "[email protected]"), | ||
), | ||
listOf( | ||
User(name = "Ryan", email = "[email protected]"), | ||
User(name = "Trevor", email = "[email protected]"), | ||
User(name = "Josh", email = "[email protected]"), | ||
), | ||
) | ||
} |
149 changes: 149 additions & 0 deletions
149
snapshots/snapshots/src/main/kotlin/com/emergetools/snapshots/compose/ComposableInvoker.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
package com.emergetools.snapshots.compose | ||
|
||
import android.util.Log | ||
import androidx.compose.runtime.reflect.ComposableMethod | ||
import androidx.compose.runtime.reflect.getDeclaredComposableMethod | ||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider | ||
import java.lang.reflect.ParameterizedType | ||
|
||
/** | ||
* Handles reflection-based invocation of Composable methods, | ||
* including support for preview parameters. | ||
*/ | ||
object ComposableInvoker { | ||
private const val TAG = "ComposableInvoker" | ||
|
||
/** | ||
* Finds and returns the appropriate ComposableMethod for the given parameters. | ||
*/ | ||
fun findComposableMethod( | ||
klass: Class<*>, | ||
methodName: String, | ||
previewProviderClass: Class<out PreviewParameterProvider<*>>? = null | ||
): ComposableMethod { | ||
val composableMethod = previewProviderClass?.let { previewProvider -> | ||
Log.d(TAG, "Looking for parameterized composable method: $methodName in class: ${klass.name}") | ||
|
||
// Find the type argument for PreviewParameterProvider | ||
val providerType = findPreviewParameterType(previewProvider) | ||
?: throw IllegalArgumentException("Unable to determine type argument for PreviewParameterProvider") | ||
|
||
// Try to find the method with either the primitive or wrapper type | ||
tryGetComposableMethod(klass, methodName, providerType) | ||
?: throw NoSuchMethodException("Could not find composable method: $methodName") | ||
} ?: run { | ||
Log.d(TAG, "Looking for composable method: $methodName in class: ${klass.name}") | ||
klass.getDeclaredComposableMethod(methodName) | ||
} | ||
return composableMethod.also { | ||
val backingMethod = composableMethod.asMethod() | ||
if (!backingMethod.isAccessible) { | ||
Log.i(TAG, "Marking composable method $methodName as accessible") | ||
backingMethod.isAccessible = true | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Gets parameters from a PreviewParameterProvider, respecting the limit if specified. | ||
*/ | ||
fun getPreviewParameters( | ||
parameterProviderClass: Class<out PreviewParameterProvider<*>>?, | ||
limit: Int? = null | ||
): List<Any?> { | ||
return parameterProviderClass?.let { | ||
val params = getPreviewProviderParameters(it) | ||
limit?.let { maxLimit -> params.take(maxLimit) } ?: params | ||
} ?: listOf(null) | ||
} | ||
|
||
private fun findPreviewParameterType(previewProviderClass: Class<*>): Class<*>? { | ||
// Look through all interfaces implemented by the class | ||
for (genericInterface in previewProviderClass.genericInterfaces) { | ||
if (genericInterface is ParameterizedType && | ||
genericInterface.rawType == PreviewParameterProvider::class.java | ||
) { | ||
// Handle different types of type arguments | ||
return when (val typeArg = genericInterface.actualTypeArguments.firstOrNull()) { | ||
// Direct class reference | ||
is Class<*> -> typeArg | ||
// Nested generic type (e.g., List<User>) | ||
is ParameterizedType -> typeArg.rawType as? Class<*> | ||
// Other cases (wildcards, type variables, etc.) | ||
else -> null | ||
} | ||
} | ||
} | ||
|
||
// Check superclass if the interface isn't found in the current class | ||
val superclass = previewProviderClass.genericSuperclass | ||
if (superclass is ParameterizedType) { | ||
return findPreviewParameterType(superclass.rawType as Class<*>) | ||
} | ||
|
||
return null | ||
} | ||
|
||
@Suppress("SwallowedException") | ||
private fun tryGetComposableMethod( | ||
klass: Class<*>, | ||
methodName: String, | ||
parameterType: Class<*> | ||
): ComposableMethod? { | ||
// Map of primitive types to their Java object wrapper classes | ||
val primitiveToWrapper: Map<Class<*>?, Class<*>?> = mapOf( | ||
Int::class.javaPrimitiveType to Integer::class.java, | ||
Long::class.javaPrimitiveType to Long::class.java, | ||
Double::class.javaPrimitiveType to Double::class.java, | ||
Float::class.javaPrimitiveType to Float::class.java, | ||
Boolean::class.javaPrimitiveType to Boolean::class.java, | ||
Byte::class.javaPrimitiveType to Byte::class.java, | ||
Short::class.javaPrimitiveType to Short::class.java, | ||
Char::class.javaPrimitiveType to Character::class.java | ||
) | ||
|
||
// Map of wrapper classes to their primitive types | ||
val wrapperToPrimitive = primitiveToWrapper.entries.associate { (k, v) -> v to k } | ||
|
||
return try { | ||
// First try with the original type | ||
klass.getDeclaredComposableMethod(methodName, parameterType) | ||
} catch (e: NoSuchMethodException) { | ||
Log.d( | ||
TAG, | ||
"Method $methodName not found with parameter type: $parameterType, trying primitive/wrapper type" | ||
) | ||
// If that fails, try with the corresponding primitive/wrapper type | ||
val alternateType: Class<*>? = when { | ||
parameterType.isPrimitive -> primitiveToWrapper[parameterType] | ||
wrapperToPrimitive.containsKey(parameterType) -> wrapperToPrimitive[parameterType] | ||
else -> null | ||
} | ||
|
||
alternateType?.let { | ||
try { | ||
klass.getDeclaredComposableMethod(methodName, it) | ||
} catch (e: NoSuchMethodException) { | ||
Log.d( | ||
TAG, | ||
"Method $methodName not found with primitive/wrapped type: $parameterType, returning null" | ||
) | ||
null | ||
} | ||
} | ||
} | ||
} | ||
|
||
private fun getPreviewProviderParameters( | ||
parameterProviderClass: Class<out PreviewParameterProvider<*>> | ||
): List<Any?> { | ||
val constructor = parameterProviderClass.constructors | ||
.singleOrNull { it.parameterTypes.isEmpty() } | ||
?.apply { isAccessible = true } | ||
?: throw IllegalArgumentException( | ||
"PreviewParameterProvider constructor can not have parameters" | ||
) | ||
val params = constructor.newInstance() as PreviewParameterProvider<*> | ||
return params.values.toList() | ||
} | ||
} |
Oops, something went wrong.