Du betrachtest gerade Jetpack Compose View Adapter: 3 Wege eine “Kuckucks-View” zu erzeugen

Jetpack Compose View Adapter: 3 Wege eine “Kuckucks-View” zu erzeugen

Innerhalb in sehr großen Android Apps mit vielen häufig wiederverwendeten Custom-Widgets, kann es durchaus Sinn machen neue Composable auch als “Legacy” View & XML Implementierungen anzubieten. Jetpack Compose View Adapter bieten uns technische Möglichkeiten Composable in bestehenden XML Layouts zu nutzen.

Richtig umgesetzt lässt sich unser View Adapter nicht mehr von anderen Custom Views unterscheiden. Wir haben unserem XML Layout eine „Kuckucks-View“ untergejubelt, die in Wahrheit ein Composable in sich trägt.

Neben großen Apps bieten sich diese in der Theorie auch für umfangreiche Widget-Bibliotheken an. Sie bilden das effektive Gegenstück zu dem Einbetten von bestehenden Views in Jetpack Compose.

Jetpack Compose AbstractComposeView
Jetpack Compose View Adapter: 3 Wege eine “Kuckucks-View” zu erzeugen 4

Erstellen eines einfachen Jetpack Compose View Adapters

Ein „View Adapter“ verfolgt dabei das Ziel neu entwickelte Compose Anteile wie gewohnt als View Klassen in XML Layouts einzubinden. Nachfolgend ein Beispiel eines „View Adapter“ für unsere obige „MediaCover“ Compose Implementierung:

View-Wrapper und internes State Management

Die Grundvoraussetzung in Android, um ein UI Element in einem XML Layout nutzen zu können ist, dass es direkt (oder über indirekte Vererbung) von einer “View”-Klasse erbt. Um in unserem View Adapter Compose Inhalte einbetten zu können werden wir von einer speziellen View erben, der AbstractComposeView. Hier können wir in einer zu überschreitenden “Content()” Methode schnell und komfortabel in die Compose-Welt wechseln:

// Extending "AbstractComposeView" to create a "View Adapter"
class MediaCoverView(context: Context) : AbstractComposeView(context) {
    // Inserting and connecting new Compose implementations
    @Composable
    override fun Content() {
        val imageBitmap = imageBitmap.value

        imageBitmap?.let {
            MediaCover(
                imageBitmap = it,
                contentDescription = "12345"
            )
        }
    }
    
    // Keep internal state
    private var imageBitmap: MutableState<ImageBitmap?> = mutableStateOf(null)

    // Looks like a regular "View API" from the outside
    var bitmap: Bitmap? = null
        set(value) {
            imageBitmap.value = value?.asImageBitmap()
            field = value
        }
}

Die zweite notwendige Maßnahme, um View und Compose Konzepte miteinander zu verbinden, ist es uns innerhalb unseres Jetpack Compose View Adapter einen internen State anzulegen, der den aktuellen Zustand unserer UI beschreibt.

Um diesen Anzeige Zustand in die Compose Welt zu transportieren brauchen wir drei Dinge:

  • Setter und Getter (inkl. “Backing Field”), um den State unserer View abbilden zu können
  • Interne “States” die unseren UI Inhalt beschreiben und die Recomposition in unseren Composable im Falle von Zustandsänderungen ermöglichen
  • Aufrufe unserer Setter müssen unseren “State” zuverlässig aktualisieren, um die Recomposition auszulösen

Wir erinnern uns: Die Art und Weise wie “UI State” unsere Anzeige beschreibt ist der fundamentalste konzeptionelle Unterschied zwischen imperativen View System auf der einen und dem deklarativen Compose UI Framework auf der anderen Seite.

Einbinden in ein XML Layout

Unseren neuen Jetpack Compose View Adapter können wir anschließend wie jede gewöhnliche View in einem XML Layout einbinden:

<!-- media_player_screen.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- Inserting our "View Adapter" -->
    <de.schroedel.composeconcepts.ui.interop.MediaCoverView
        android:id="@+id/mediaCoverWrapper"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

    <!-- Rest of media screen -->
</LinearLayout>

Innerhalb unseres Fragments binden wir unseren View Adapter mittels ViewBinding a die zugehörigen Daten an. Wie wir es von “Legacy Views” gewohnt sind.

Styling- und Theme-Attribute

Zusätzlich ist es erwähnenswert, dass für unseren View Adapter ALLE Funktionen einer regulären „ViewGroup“ zur Verfügung stehen. Die „AbstractComposeView“ von der wir erben macht lediglich eine Ausnahme: Wir können keine zusätzlichen Views neben unserem Composable hinzufügen.

Zusätzliche Styling- und Theming-Attribute können wie gewohnt definiert werden für unseren View Adapter.

Grenzen unserer View-Fassade

Wie bei jeder Compose Interop API gibt es technisch in der Praxis keine Grenzen. Gleichzeitig gibt es Situationen in denen sich Jetpack Compose View Adapter schlicht nicht rechnen, wenn wir den Kosten-Nutzen-Faktor betrachten.

„ViewGroups“ und deren „Kind-Views“

Eng aneinander gekoppelte ViewGroups und deren Kind-Views zu abstrahieren ist möglich, aber nur mit sehr anstrengenden Verrenkungen der Compose Interop APIs. Wir müssten sowohl unseren Compose Container, als auch dessen Kind-Composable jeweils in „AbstractComposeViews“ wrappen.

Diese Kombination wird sehr aufwändig, da wir:

  1. Unsere „addView“-Methode in unserer „Container Adapter“ überschreiben müssen, da unser „Container View Wrapper“ das Hinzufügen weiterer Child Views nicht zulässt.
  2. Jede hinzugefügte „Child View“ in State „entpacken“ müssen, um diesen in unserer „Content()“-Methode wieder in Composable „verpacken“ müssen.

Aus eigener Erfahrung: Die Lösung dafür kann nur komplex und hässlich werden …

Eine reine Jetpack Compose-Implementierung ist hier die bessere Alternative, da sie deutlich weniger aufwändig und im Vergleich so gut wir keine Komplexität enthält. Diese lässt sich bekanntlich auch leicht durch eine „ComposeView“ an der richtigen Stelle in seine UI integriert.

Composable mit vielen Zuständen

Wie in obigen Beispiel unserer Jetpack Compose View Adapter Implementierung zu sehen, benötigen wir neben unserer View-Fassade zusätzliche Setter, Getter und damit verbundenes internes State-Handling in unserer Implementierung. Gerade beim abstrahieren von komplexen Composable, die mehrere Zustandswerte als Input erwarten, wird dies gerne schnell komplex und aufwändig:

class MediaTimeBarView(context: Context) : AbstractComposeView(context) {
    // Create individual internal states for each input parameter.
    private var elapsedTimeState = mutableIntStateOf(0)
    private var totalTimeState = mutableIntStateOf(0)
    private var elapsedTimeTextState = mutableStateOf("")
    private var totalTimeTextState = mutableStateOf("")
    private var onPlayTimeChangedListenerState = mutableStateOf<((Int) -> Unit)?>(null)

    // Introduce individual setters to modify internal states.
    var elapsedTime: Int = 0
        set(value) {
            field = value
            elapsedTimeState.intValue = value
        }

    var totalTime: Int = 0
        set(value) {
            field = value
            totalTimeState.intValue = value
        }

    var elapsedTimeText: String = ""
        set(value) {
            field = value
            elapsedTimeTextState.value = value
        }

    var totalTimeText: String = ""
        set(value) {
            field = value
            totalTimeTextState.value = value
        }

    fun setOnPlayTimeChangedListener(listener: (Int) -> Unit) {
        onPlayTimeChangedListenerState.value = listener
    }

    @Composable
    override fun Content() {
        // Forward internal state to our Composable implementation.
        MediaTimeBar(
            modifier = Modifier.fillMaxWidth(),
            totalTime = totalTimeState.intValue,
            elapsedTime = elapsedTimeState.intValue,
            onPlayTimeChanged = { onPlayTimeChangedListenerState.value?.invoke(it) },
            totalTimeText = {
                Text(text = totalTimeTextState.value)
            },
            elapsedTimeText = {
                Text(text = elapsedTimeTextState.value)
            }
        )
    }
}

Aus eigener Erfahrung: Benötigt ein Composable mehr als 3 externe Zustände fühlt sich die zusätzliche benötigte Komplexität des internen State-Managements bereits “falsch” an.

Wir müssen abwägen, ob es uns diese zusätzliche Komplexität wirklich wert ist umzusetzen und viel wichtiger: langfristig zu pflegen! Gerade die Komplexität und Fehleranfälligkeit des ViewBindings ist es ja gerade, die wir durch den Einsatz von Jetpack Compose loswerden wollen.

Konfiguration durch komplexe XML-Styles

Eines haben Views und Composable gemeinsam: So richtig Sinn ergeben sie vor allem dann, wenn sie “stylebar” sind. “Stylebar” bedeutet in diesem Kontext, dass ich ihr äußeres Erscheinungsbild durch zusätzliche Konfiguration von außen beeinflussen kann. Im Falle von Views sind das I.d.R. XML Style Definitionen, für Compose sind dies vorzugsweise Modifier oder Funktions-Parameter.

Generische View Styling-Parameter wie “Paddings” oder Größen-Definitionen sind hierbei kein Problem. Spannend wird es, wenn es um “Custom Styling Attribute” geht. Diese werden explizit für eine View definiert und müssen programmatisch in einer View ausgelesen werden:

class MediaCoverFlowView @JvmOverloads constructor(
    context: Context,
    attributeSet: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AbstractComposeView(context, attributeSet, defStyleAttr) {
    // Keep track of resolved styling properties.
    private var coverBlurRadius: Float = 0f
    private var coverSize: Dp = 100.dp
    private var textStyle: TextStyle = TextStyle()

    init {
        // Obtain custom styling attributes from XML styles.
        context.obtainStyledAttributes(attributeSet, R.styleable.MediaCoverFlowView, defStyleAttr, 0)
            .apply {
                coverBlurRadius = getFloat(R.styleable.MediaCoverFlowView_backgroundBlur, 0f)
                coverSize = getDimension(R.styleable.MediaCoverFlowView_coverSize, 100f).dp
                // Converting a TextAppearance to a TextStyle is not fun ...
                textStyle = getResourceId(R.styleable.MediaCoverFlowView_captionTextAppearance, 0)
                    .convertToTextStyle(/* ??? */)
            }
            .recycle()
    }

    @Composable
    override fun Content() {
        // Use resolved styling attributes within Compose
    }
}

Man erkennt schnell, dass dies sehr schnell sehr umfangreich und komplex werden kann. Gerade bei Datentypen, die so entweder nicht in einem View System existieren oder andersrum nicht dem Compose Framework bekannt sind. Für solche Fälle sind Workarounds und viel Boilerplate Code in Form von Mappings notwendig.

Aus eigener Erfahrung: Spätestens bei mehr als drei solcher selbst definierten Styling Attributen macht das keinen Spaß mehr …

Fazit: Jetpack Compose View Adapter sind nicht immer eine gute Lösung

Wir müssen uns gut überlegen, ob sich all der Aufwand am Ende für uns rechnen wird. Gerade das ersatzlose Wegfallen des Definierens und Auslesens von Styling Attribute in Compose ist einer der größten Boilerplate Reduzierungen, die wir in diesem Anwendungsfall wieder einführen müssten.

Für noch mehr hilfreiche Tipps, wie du deine Bestands App zu Jetpack Compose migrieren kannst findest du hier weitere Beiträge.

Jetpack Compose View Adapter: 3 Wege eine “Kuckucks-View” zu erzeugen

Alles über Jetpack Compose & SW Engineering!

Abonniere meinen Newsletter!

Ich schicke dir keinen Spam! Lies meine Datenschutzhinweise für mehr Informationen.

Alles über Jetpack Compose & Software Engineering!

Abonniere meinen kostenlosen Newsletter und verpasse keinen neuen Artikel zu Jetpack Compose oder Software Engineering!

Ich schicke dir keinen Spam! Lies meine Datenschutzhinweise für mehr Informationen.

Give me some feedback, suggestions or just leave a Reply