diff --git a/spine-android/app/src/main/java/com/esotericsoftware/spine/DisableRendering.kt b/spine-android/app/src/main/java/com/esotericsoftware/spine/DisableRendering.kt new file mode 100644 index 000000000..c3ba09153 --- /dev/null +++ b/spine-android/app/src/main/java/com/esotericsoftware/spine/DisableRendering.kt @@ -0,0 +1,196 @@ +package com.esotericsoftware.spine + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavHostController +import com.esotericsoftware.spine.android.AndroidSkeletonDrawable +import com.esotericsoftware.spine.android.AndroidTextureAtlas +import com.esotericsoftware.spine.android.SpineController +import com.esotericsoftware.spine.android.SpineView +import com.esotericsoftware.spine.android.utils.SkeletonDataUtils +import kotlin.random.Random + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DisableRendering(nav: NavHostController) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = Destination.DisableRendering.title) }, + navigationIcon = { + IconButton({ nav.navigateUp() }) { + Icon( + Icons.Rounded.ArrowBack, + null, + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .padding() + .onGloballyPositioned { coordinates -> + print(coordinates.size.toSize()) + } + ) { + Column( + modifier = Modifier + .padding(8.dp) + ) { + Text("Scroll spine boys out of the viewport") + Text("Rendering is disabled when the spine view moves out of the viewport, preserving CPU/GPU resources.") + } + SpineBoys() + } + } +} + +@Composable +fun SpineBoys() { + var boxSize by remember { mutableStateOf(Size.Zero) } + val offsetX = remember { mutableFloatStateOf(0f) } + val offsetY = remember { mutableFloatStateOf(0f) } + + Box( + modifier = Modifier + .fillMaxSize() + .clipToBounds() + .onGloballyPositioned { coordinates -> + boxSize = coordinates.size.toSize() + } + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + offsetX.floatValue += dragAmount.x + offsetY.floatValue += dragAmount.y + } + } + ) { + if (boxSize != Size.Zero) { + val contentSize = boxSize * 4f + + val context = LocalContext.current + val cachedAtlas = + remember { AndroidTextureAtlas.fromAsset("spineboy.atlas", context) } + val cachedSkeletonData = remember { + SkeletonDataUtils.fromAsset( + cachedAtlas, + "spineboy-pro.json", + context + ) + } + + val spineboys = remember { + val rng = Random(System.currentTimeMillis()) + List(100) { index -> + val scale = 0.1f + rng.nextFloat() * 0.2f + val position = Offset( + rng.nextFloat() * contentSize.width, + rng.nextFloat() * contentSize.height + ) + SpineBoyData( + index, + scale, + position, + if (index == 99) "hoverboard" else "walk" + ) + } + } + + spineboys.forEach { spineBoyData -> + + val isSpineBoyVisible = remember { mutableStateOf(false) } + + Box(modifier = Modifier + .offset { + IntOffset( + (-(contentSize.width / 2) + spineBoyData.position.x + offsetX.floatValue.toInt()).toInt(), + (-(contentSize.height / 2) + spineBoyData.position.y + offsetY.floatValue.toInt()).toInt(), + ) + } + .size( + (boxSize.width * spineBoyData.scale).dp, + (boxSize.height * spineBoyData.scale).dp + ) + .onGloballyPositioned { coordinates -> + val positionInRoot = coordinates.positionInParent() + val size = coordinates.size.toSize() + + val isInViewport = positionInRoot.x < boxSize.width && + positionInRoot.x + size.width > 0 && + positionInRoot.y < boxSize.height && + positionInRoot.y + size.height > 0 + + isSpineBoyVisible.value = isInViewport + } + ) { + AndroidView( + factory = { ctx -> + SpineView.loadFromDrawable( + AndroidSkeletonDrawable(cachedAtlas, cachedSkeletonData), + ctx, + SpineController { + it.animationState.setAnimation( + 0, + spineBoyData.animation, + true + ) + } + ).apply { + isRendering = false + } + }, + update = { view -> + view.isRendering = isSpineBoyVisible.value + } + ) + } + } + } + } +} + +data class SpineBoyData( + val id: Int, + val scale: Float, + val position: Offset, + val animation: String +) + diff --git a/spine-android/app/src/main/java/com/esotericsoftware/spine/MainActivity.kt b/spine-android/app/src/main/java/com/esotericsoftware/spine/MainActivity.kt index a2cf0889c..07622c199 100644 --- a/spine-android/app/src/main/java/com/esotericsoftware/spine/MainActivity.kt +++ b/spine-android/app/src/main/java/com/esotericsoftware/spine/MainActivity.kt @@ -20,7 +20,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -67,7 +66,7 @@ fun AppContent() { Destination.DressUp, Destination.IKFollowing, Destination.Physics, - Destination.TheBoys + Destination.DisableRendering ), paddingValues ) @@ -117,9 +116,9 @@ fun AppContent() { } composable( - Destination.TheBoys.route + Destination.DisableRendering.route ) { - TheBoys(navController) + DisableRendering(navController) } } } @@ -188,5 +187,5 @@ sealed class Destination(val route: String, val title: String) { data object DressUp : Destination("dressUp", "Dress Up") data object IKFollowing : Destination("ikFollowing", "IK Following") data object Physics: Destination("physics", "Physics (drag anywhere)") - data object TheBoys: Destination("theBoys", "100 Spine Boys") + data object DisableRendering: Destination("disableRendering", "Disable Rendering") } diff --git a/spine-android/app/src/main/java/com/esotericsoftware/spine/TheBoys.kt b/spine-android/app/src/main/java/com/esotericsoftware/spine/TheBoys.kt deleted file mode 100644 index f4cef243e..000000000 --- a/spine-android/app/src/main/java/com/esotericsoftware/spine/TheBoys.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.esotericsoftware.spine - -import android.view.View -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.scale -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.toSize -import androidx.compose.ui.viewinterop.AndroidView -import androidx.navigation.NavHostController -import com.esotericsoftware.spine.android.AndroidSkeletonDrawable -import com.esotericsoftware.spine.android.AndroidTextureAtlas -import com.esotericsoftware.spine.android.SpineController -import com.esotericsoftware.spine.android.SpineView -import com.esotericsoftware.spine.android.utils.SkeletonDataUtils -import kotlin.random.Random - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TheBoys(nav: NavHostController) { - Scaffold( - topBar = { - TopAppBar( - title = { Text(text = Destination.TheBoys.title) }, - navigationIcon = { - IconButton({ nav.navigateUp() }) { - Icon( - Icons.Rounded.ArrowBack, - null, - ) - } - } - ) - } - ) { paddingValues -> - var viewportSize by remember { mutableStateOf(Size.Zero) } - val offsetX = remember { mutableFloatStateOf(0f) } - val offsetY = remember { mutableFloatStateOf(0f) } - - Box( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - .clipToBounds() - .onGloballyPositioned { coordinates -> - viewportSize = coordinates.size.toSize() - } - .pointerInput(Unit) { - detectDragGestures { change, dragAmount -> - change.consume() - offsetX.floatValue += dragAmount.x - offsetY.floatValue += dragAmount.y - } - } - ) { - if (viewportSize != Size.Zero) { - val contentSize = viewportSize * 4f - - val context = LocalContext.current - val cachedAtlas = remember { AndroidTextureAtlas.fromAsset("spineboy.atlas", context) } - val cachedSkeletonData = remember { SkeletonDataUtils.fromAsset(cachedAtlas, "spineboy-pro.json", context) } - - val spineboys = remember { - val rng = Random(System.currentTimeMillis()) - List(100) { index -> - val scale = 0.1f + rng.nextFloat() * 0.2f - val position = Offset(rng.nextFloat() * contentSize.width, rng.nextFloat() * contentSize.height) - SpineBoyData(scale, position, if (index == 99) "hoverboard" else "walk") - } - } - - spineboys.forEach { spineBoyData -> - Box(modifier = Modifier - .offset { - IntOffset( - (-(contentSize.width / 2) + spineBoyData.position.x + offsetX.floatValue.toInt()).toInt(), - (-(contentSize.height / 2) + spineBoyData.position.y + offsetY.floatValue.toInt()).toInt(), - ) - } - .scale(spineBoyData.scale) - ) { - AndroidView( - factory = { ctx -> - SpineView.loadFromDrawable( - AndroidSkeletonDrawable(cachedAtlas, cachedSkeletonData), - ctx, - SpineController { - it.animationState.setAnimation(0, spineBoyData.animation, true) - } - ) - }, - ) - } - } - } - } - } -} - -data class SpineBoyData( - val scale: Float, - val position: Offset, - val animation: String -) - diff --git a/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineController.java b/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineController.java index 48b6efe12..5a5799438 100644 --- a/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineController.java +++ b/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineController.java @@ -119,11 +119,15 @@ public class SpineController { } public void pause() { - playing = false; + if (playing) { + playing = false; + } } public void resume() { - playing = true; + if (!playing) { + playing = true; + } } public Point toSkeletonCoordinates(Point position) { @@ -132,6 +136,22 @@ public class SpineController { return new Point((int) (x / scaleX - offsetX), (int) (y / scaleY - offsetY)); } + public void setOnBeforeUpdateWorldTransforms(@Nullable SpineControllerCallback onBeforeUpdateWorldTransforms) { + this.onBeforeUpdateWorldTransforms = onBeforeUpdateWorldTransforms; + } + + public void setOnAfterUpdateWorldTransforms(@Nullable SpineControllerCallback onAfterUpdateWorldTransforms) { + this.onAfterUpdateWorldTransforms = onAfterUpdateWorldTransforms; + } + + public void setOnBeforePaint(@Nullable SpineControllerBeforePaintCallback onBeforePaint) { + this.onBeforePaint = onBeforePaint; + } + + public void setOnAfterPaint(@Nullable SpineControllerAfterPaintCallback onAfterPaint) { + this.onAfterPaint = onAfterPaint; + } + protected void setCoordinateTransform(double offsetX, double offsetY, double scaleX, double scaleY) { this.offsetX = offsetX; this.offsetY = offsetY; diff --git a/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineView.java b/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineView.java index 1d6174235..498034610 100644 --- a/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineView.java +++ b/spine-android/spine-android/src/main/java/com/esotericsoftware/spine/android/SpineView.java @@ -42,13 +42,20 @@ import android.graphics.Canvas; import android.os.Handler; import android.os.Looper; import android.util.AttributeSet; +import android.util.Log; import android.view.Choreographer; import android.view.View; import androidx.annotation.NonNull; +import java.io.Console; import java.io.File; import java.net.URL; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.Timer; +import java.util.TimerTask; public class SpineView extends View implements Choreographer.FrameCallback { @@ -138,6 +145,7 @@ public class SpineView extends View implements Choreographer.FrameCallback { private float x = 0; private float y = 0; private final SkeletonRenderer renderer = new SkeletonRenderer(); + private Boolean rendering = true; private Bounds computedBounds = new Bounds(); private SpineController controller; @@ -235,6 +243,14 @@ public class SpineView extends View implements Choreographer.FrameCallback { updateCanvasTransform(); } + public Boolean isRendering() { + return rendering; + } + + public void setRendering(Boolean rendering) { + this.rendering = rendering; + } + private void loadFrom(AndroidSkeletonDrawableLoader loader) { Handler mainHandler = new Handler(Looper.getMainLooper()); Thread backgroundThread = new Thread(() -> { @@ -253,7 +269,7 @@ public class SpineView extends View implements Choreographer.FrameCallback { @Override public void onDraw (@NonNull Canvas canvas) { super.onDraw(canvas); - if (controller == null || !controller.isInitialized()) { + if (controller == null || !controller.isInitialized() || !rendering) { return; }