Jetpack Compose у 3D-світі: інтерактивні UI-текстури в OpenGL ES

Хоча сценарії використання цієї техніки досить специфічні, я хочу розповісти, як малювати Android UI на поверхнях OpenGL за допомогою механізму віртуального дисплея.

Основні терміни:

  • Display - об’єкт, що описує логічний дисплей. Він містить характеристики реальних, мережевих або віртуальних екранів, підключених до Android-пристрою. Документація.

  • VirtualDisplay - це різновил дисплею, який рендерить зображення не на фізичну панель, а у спеціальний буфер пам’яті - Surface. Документація.

  • DisplayManager - системний сервіс Android, що дозволяє керувати всіма доступними дисплеями (отримувати характеристики та створювати віртуальні). Документація.

  • Presentation - спеціалізований компонент, побудований на базі Dialog. Він не має власного повноцінного життєвого циклу (як Activity), що спрощує роботу з ним, і призначений для виведення контенту на будь-який додатковий дисплей. Документація.

Малювання за допомогою OpenGL

Для початку створимо простий приклад, який малюватиме лише прямокутник. Тільки після того, як ми переконаємося, що все працює, почнемо ускладнювати код і додамо виведення нашого віртуального дисплея на цей об’єкт.

Найпростіший спосіб використати OpenGL у Jetpack Compose — це обгорнути GLSurfaceView через компонент AndroidView.

У блоці ініціалізації нашого GLSurfaceView ми вказуємо стандарт OpenGL ES 3.0. Також я перевизначив метод surfaceDestroyed, щоб відстежити момент зникнення в’юшки з екрана - саме в цей момент потрібно буде звільнити ресурси.

interface PreviewRenderer: GLSurfaceView.Renderer {
    fun onSurfaceDestroyed()
}

@Composable
fun OpenGLPreview(
    modifier: Modifier = Modifier,
    renderer: PreviewRender,
) {
    AndroidView(
        factory = { context ->
            object:GLSurfaceView(context){
                init {
                    setEGLContextClientVersion(3)
                    setRenderer(renderer)
                }
                override fun surfaceDestroyed(holder: SurfaceHolder) {
                    super.surfaceDestroyed(holder)
                    renderer.onSurfaceDestroyed()
                }
            }
        },
        modifier = modifier,
    )
}

Наша єдина MainActivity матиме такий вигляд. Тут ми ініціалізуємо рендерер та встановлюємо наш OpenGLPreview як основний контент:

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        val renderer = BoxRenderer()
        setContent {
            AndroidUiOnGlTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    OpenGLPreview(
                        modifier = Modifier
                            .fillMaxSize()
                            .padding(innerPadding),
                        renderer = renderer
                    )
                }
            }
        }
    }
}

Логіку малювання ми відокремлюємо від GLSurfaceView - вона повинна знаходитися в реалізації GLSurfaceView.Renderer. У нашому випадку ми використовуємо інтерфейс PreviewRenderer, оскільки він дозволяє обробити завершення роботи (метод onSurfaceDestroyed). На початковому етапі це може здатися зайвим, проте коректне звільнення ресурсів стане критично важливим далі, коли ми додамо роботу з віртуальним дисплеєм.

class BoxRenderer(
    private val rotationDurationMs: Long = 15000L
) : PreviewRenderer {
    private val vertexShaderCode = """
        attribute vec4 aPosition;
        attribute vec2 aTexCoord;
        varying vec2 vTexCoord;
        uniform mat4 mvpMatrix;
        void main() {
            gl_Position = mvpMatrix * aPosition;
            vTexCoord = aTexCoord;
        }
    """.trimIndent()

    private val fragmentShaderCode = """
        precision mediump float;
        varying vec2 vTexCoord;
        void main() {
            gl_FragColor = vec4(vTexCoord.x, vTexCoord.y, 0.0, 1.0);
        }
    """.trimIndent()

    private val vertices = floatArrayOf(
        -1.0f,  1.0f, 0f,  // Top left
        -1.0f, -1.0f, 0f,  // Bottom left
        1.0f,  1.0f, 0f,  // Top right
        1.0f, -1.0f, 0f   // Bottom right
    )

    private val texCoords = floatArrayOf(
        0f, 0f, // Top left
        0f, 1f, // Bottom left
        1f, 0f, // Top right
        1f, 1f  // Bottom right
    )

    private var programId: Int = 0
    private lateinit var vertexBuffer: FloatBuffer
    private lateinit var texCoordBuffer: FloatBuffer
    private var positionHandle: Int = 0
    private var texCoordHandle: Int = 0
    private var matrixHandle: Int = 0
    private val projectionMatrix = FloatArray(16)
    private val viewMatrix = FloatArray(16)
    private val vpMatrix = FloatArray(16)
    private val modelMatrix = FloatArray(16)
    private val mvpMatrix = FloatArray(16)

    private fun createFloatBuffer(data: FloatArray): FloatBuffer {
        return ByteBuffer.allocateDirect(data.size * 4).run {
            order(ByteOrder.nativeOrder())
            asFloatBuffer().apply {
                put(data)
                position(0)
            }
        }
    }

    private fun loadShader(type: Int, shaderCode: String): Int {
        val shader = GLES20.glCreateShader(type)
        GLES20.glShaderSource(shader, shaderCode)
        GLES20.glCompileShader(shader)
        val compileStatus = IntArray(1)
        GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
        if (compileStatus[0] == 0) {
            val error = GLES20.glGetShaderInfoLog(shader)
            GLES20.glDeleteShader(shader)
            throw RuntimeException("Compile error ($type): $error")
        }
        return shader
    }

    fun createProgram(vertexCode: String, fragmentCode: String): Int {
        val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexCode)
        val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentCode)
        val program = GLES20.glCreateProgram()
        GLES20.glAttachShader(program, vertexShader)
        GLES20.glAttachShader(program, fragmentShader)
        GLES20.glLinkProgram(program)
        val linkStatus = IntArray(1)
        GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
        if (linkStatus[0] == 0) {
            val error = GLES20.glGetProgramInfoLog(program)
            GLES20.glDeleteProgram(program)
            throw RuntimeException("Program link error: $error")
        }
        return program
    }

    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        programId = createProgram(vertexShaderCode, fragmentShaderCode)
        // prepare geometry
        vertexBuffer = createFloatBuffer(vertices)
        texCoordBuffer = createFloatBuffer(texCoords)
        // get attributes
        positionHandle = GLES20.glGetAttribLocation(programId, "aPosition")
        texCoordHandle = GLES20.glGetAttribLocation(programId, "aTexCoord")
        matrixHandle = GLES20.glGetUniformLocation(programId, "mvpMatrix")
        GLES20.glEnable(GLES20.GL_DEPTH_TEST)
    }

    override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        GLES20.glViewport(0, 0, width, height)
        val ratio = width.toFloat() / height.toFloat()
        // lens
        Matrix.perspectiveM(projectionMatrix, 0, 53.1f, ratio, 1f, 10f)
        // camera
        Matrix.setLookAtM(viewMatrix, 0, 0f, 0f, 4f, 0f, 0f, 0f, 0f, 1f, 0f)
        // multiply
        Matrix.multiplyMM(vpMatrix, 0, projectionMatrix, 0, viewMatrix, 0)
    }

    override fun onDrawFrame(gl: GL10?) {
        GLES20.glClearColor(0.1f, 0.3f, 0.5f, 1.0f)
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT or GLES20.GL_DEPTH_BUFFER_BIT)
        GLES20.glUseProgram(programId)

        val angleInDegrees = 360.0f * (SystemClock.uptimeMillis() % rotationDurationMs).toFloat() / rotationDurationMs.toFloat()
        Matrix.setIdentityM(modelMatrix, 0)
        // анміація обертання навколо X та Y
        Matrix.rotateM(modelMatrix, 0, angleInDegrees, 0.5f, 1f, 0f)
        Matrix.multiplyMM(mvpMatrix, 0, vpMatrix, 0, modelMatrix, 0)

        GLES20.glUniformMatrix4fv(matrixHandle, 1, false, mvpMatrix, 0)
        GLES20.glEnableVertexAttribArray(positionHandle)
        GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer)

        GLES20.glEnableVertexAttribArray(texCoordHandle)
        GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer)

        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)

        GLES20.glDisableVertexAttribArray(positionHandle)
        GLES20.glDisableVertexAttribArray(texCoordHandle)
    }

    override fun onSurfaceDestroyed() {
        Log.d("GL", "Surface destroyed, releasing resources")
    }
}

Якщо все реалізовано правильно, на екрані з’явиться анімований кольоровий прямокутник, що обертається:


 

Створення віртуального дисплея та налаштування Presentation

Переходимо до практичної реалізації. На цьому етапі необхідно виконати наступні кроки:

  • Підготувати об’єкт Presentation - це клас, відповідальний за те що ми хочемо показати на віртуальному дисплеї.

  • Створити OES-текстуру та об’єкт Surface. На базі ідентифікатора текстури створюється SurfaceTexture, а з неї — Surface. Усе, що рендериться на цьому Surface, автоматично потраплятиме в OES-текстуру.

  • Створити віртуальний дисплей VirtualDisplay - використовуючи отриманий Surface, ми реєструємо в системі новий логічний дисплей через DisplayManager.

  • Оновити цикл рендерингу - потрібно додати логіку малювання кадрів, яка зчитує дані з OES-текстури та виводить їх на основний екран.

Створення нащадка Presentation

Почнемо з реалізації класу презентації. Розмістимо на ній кілька простих стандартних View-елементів:

class ViewPresentation(
    context: Context,
    display: Display,
) : Presentation(context, display) {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val layout = LinearLayout(context).apply {
            orientation = LinearLayout.VERTICAL
            addView(TextView(context).apply {
                text = "Це TextView на віртуальному екрані"
                textSize = 24f
                setTextColor(Color.BLACK)
                gravity = Gravity.CENTER
            }, LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.WRAP_CONTENT
            ))

            addView(Button(context).apply {
                text = "А це кнопка"
            }, LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT
            ))

            addView(SeekBar(context).apply {
                max = 100
                progress = 50
            }, LinearLayout.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.WRAP_CONTENT
            ))
        }
        
        setContentView(
            layout,
            ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
        )
    }
}

Ми не можемо створити презентацію безпосередньо в MainActivity, бо потрібен класс дисплея. Але й прописувати цю логіку прямо в коді ініціалізації віртуального дисплея - теж не гуд, це порушує принцип розділення відповідальності. Тому винесемо створення об’єкта в окремий інтерфейс фабрики:

interface PresentationFactory {
    fun create(
        displayContext: Context,
        display: Display,
    ): Presentation
}

class ViewPresentationFactory : PresentationFactory {
    override fun create(
        displayContext: Context,
        display: Display,
    ): Presentation =
        ViewPresentation(displayContext, display)
}

Створення Surface

Для створення Surface внесемо зміни до класу BoxRenderer з попереднього прикладу.

Порада: Краще створити копію класу з іншою назвою та просто перемкнути посилання на 
нього в MainActivity. Це дозволить у разі виникнення проблем швидко повернутися до 
робочої версії коду для дебагу або порівняння логіки.
    // шейдер під OES текстури
    private val fragmentShaderCodeOES = """#extension GL_OES_EGL_image_external : require
        precision mediump float;
        varying vec2 vTexCoord;
        uniform samplerExternalOES uTexture;
        void main() {
            gl_FragColor = texture2D(uTexture, vTexCoord);
        }
    """.trimIndent()
    private var textureOesId: Int = 0
    private var textureHandle: Int = 0
    private var surfaceTexture: SurfaceTexture? = null
    private var surface: Surface? = null

    private fun setupOutputSurface(width: Int, height: Int) {
        val textures = IntArray(1)
        GLES20.glGenTextures(1, textures, 0)
        textureOesId = textures[0]
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureOesId)
        GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
        GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
        GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
        GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
        surfaceTexture = SurfaceTexture(textureOesId)
        surfaceTexture?.setDefaultBufferSize(width, height)
        surface = Surface(surfaceTexture)
    }

    private fun releaseOutputSurface() {
        surface?.release()
        surface = null
        surfaceTexture?.release()
        surfaceTexture = null
        if (textureOesId != 0) {
            GLES20.glDeleteTextures(1, intArrayOf(textureOesId), 0)
            textureOesId = 0
        }
    }

У методі setupOutputSurface ми створюємо OES-текстуру (GL_TEXTURE_EXTERNAL_OES). Це спеціальний тип текстур, призначений для роботи з графічними даними від зовнішніх джерел (щодо блоку GPU), таких як камера або відеодекодер. Саме цей тип текстури необхідний для створення Surface, на якому буде рендеритися вміст нашого віртуального дисплея.

Оскільки робота з OES-текстурами відрізняється від стандартних, нам знадобиться інший фрагментний шейдер. Тому при створенні шейдерної програми ми вказуємо fragmentShaderCodeOES:

    override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
        // Використовуємо новий шейдер для підтримки зовнішніх текстур
        programId = createProgram(vertexShaderCode, fragmentShaderCodeOES)
        ...
        matrixHandle = GLES20.glGetUniformLocation(programId, "mvpMatrix")
        // Отримуємо хендл uniform-змінної для текстури
        textureHandle = GLES20.glGetUniformLocation(programId, "uTexture")

Метод setupOutputSurface слід викликати в колбеку onSurfaceChanged. Саме там ми отримуємо актуальні розміри нашого OpenGL-контексту і можемо розрахувати габарити Surface для віртуального дисплея. У цьому прикладі я створюю його розміром у половину мінімальної сторони екрана:

    override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        GLES20.glViewport(0, 0, width, height)
        val virtualSurfaceSize = min(width, height) / 2
        // Звільняємо ресурси, виділені раніше, та створюємо новий Surface
        releaseOutputSurface();
        setupOutputSurface(virtualSurfaceSize, virtualSurfaceSize)

Створення віртуального дисплея

Логіку керування віртуальним дисплеєм доцільно винести в окремий клас-контролер. Це дозволить розділити підготовку графічного контенту та безпосереднє керування життєвим циклом дисплея.

class VirtualDisplayController(
    private val context: Context,
    private val presentationFactory: PresentationFactory
) {
    private var virtualDisplay: VirtualDisplay? = null
    private var presentation: Presentation? = null
    private var width: Int = 0
    private var height: Int = 0

    fun createVirtualDisplay(
        width: Int,
        height: Int,
        surface: Surface) {
        if (virtualDisplay != null) {
            throw IllegalStateException("Virtual display already exists")
        }
        this.width = width
        this.height = height
        val mainHandler = Handler(Looper.getMainLooper())
        val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
        
        // Підписуємося на події DisplayManager, щоб дізнатися, коли дисплей буде створено
        displayManager.registerDisplayListener(object : DisplayManager.DisplayListener {
            override fun onDisplayAdded(displayId: Int) {
                // Перевіряємо, чи це саме наш новий віртуальний дисплей
                if (displayId == virtualDisplay?.display?.displayId) {
                    try {
                        val newDisplay = displayManager.getDisplay(displayId)
                        if (newDisplay != null) {
                            // Створюємо спеціальний контекст для цього дисплея
                            val displayContext = context.createDisplayContext(newDisplay)
                            val presentation = presentationFactory.create(displayContext, newDisplay)
                            presentation.show()
                            this@VirtualDisplayController.presentation = presentation
                        }
                    } catch (e: Exception) {
                        Log.e("GL", "Not found: $displayId", e)
                    }
                    // Відписуємося після успішного додавання
                    displayManager.unregisterDisplayListener(this)
                }
            }
            override fun onDisplayRemoved(displayId: Int) {}
            override fun onDisplayChanged(displayId: Int) {}
        }, mainHandler)

        // Налаштування прапорців: дисплей для презентацій та 
        // відображення лише власного контенту
        val flags = DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
                DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY
        virtualDisplay = displayManager.createVirtualDisplay(
            "WebViewDisplay", width, height, 240, surface, flags
        )
    }

    fun destroyVirtualDisplay() {
        presentation?.dismiss()
        presentation = null
        virtualDisplay?.release()
        virtualDisplay = null
    }
}

У контролері реалізовано два основні методи: createVirtualDisplay та destroyVirtualDisplay.

Процес створення віртуального дисплея є асинхронним і займає певний час. Тому нам потрібно підписатися на оновлення станів у DisplayManager і дочекатися виклику onDisplayAdded з ідентифікатором нашого нового дисплея. Щойно це трапиться, ми можемо ініціалізувати та відобразити Presentation і одразу після цього відмінити підписку.

Оновлюємо код малювання та з’єднуємо все разом

Повертаємося до нашого класу BoxRenderer (або його копії). Нам потрібно передати контролер віртуального дисплея через конструктор, а в методі onSurfaceChanged - ініціалізувати сам дисплей.

class BoxRenderer(
    private val virtualDisplayCtrl: VirtualDisplayController,
...
    override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
        ...        
        // видаляємо старий дисплей та surface
        releaseOutputSurface()
        virtualDisplayCtrl.destroyVirtualDisplay()
        // створюємо нові
        setupOutputSurface(virtualSurfaceSize, virtualSurfaceSize)
        surface?.let {
            virtualDisplayCtrl.createVirtualDisplay(virtualSurfaceSize, virtualSurfaceSize, it)    
        }

А тепер — безпосередньо процес рендерингу (цикл onDrawFrame):

    override fun onDrawFrame(gl: GL10?) {
        ...

        GLES20.glUniformMatrix4fv(matrixHandle, 1, false, mvpMatrix, 0)
        GLES20.glEnableVertexAttribArray(positionHandle)
        GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, false, 0, vertexBuffer)

        GLES20.glEnableVertexAttribArray(texCoordHandle)
        GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, false, 0, texCoordBuffer)

        // Важливий момент - потрібно обов'язвоко викликати updateTexImage. 
        // Це "витягує" останній кадр із нашого Presentation.
        surfaceTexture?.updateTexImage()

        // Активуємо та прив'язуємо OES-текстуру
        GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
        GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureOesId)
        GLES20.glUniform1i(textureHandle, 0)

        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
        ...
    }        

Важливим моментом тут є виклик surfaceTexture?.updateTexImage(). Без нього OpenGL буде використовувати старий кадр із буфера, і картинка не оновлюватиметься. Також зверніть увагу на тип текстури GL_TEXTURE_EXTERNAL_OES - звичайна GL_TEXTURE_2D тут не спрацює.

Наостанок оновимо код у нашій MainActivity, щоб правильно зібрати всю ієрархію об’єктів:

    val displayController = VirtualDisplayController(
        context = this@MainActivity,
        presentationFactory = ViewPresentationFactory()
    )
    val renderer = BoxRenderer(
        virtualDisplayCtrl = displayController
    )

Якщо все налаштовано вірно, ми отримаємо анімований об’єкт, на грані якого рендериться повноцінний Android UI:

Малюємо Compose на презентації

Перехід на Jetpack Compose не потребує змін в OpenGL-коді. Достатньо лише змінити реалізацію класу Presentation. Оскільки Compose будує власне дерево компонентів, ми використовуємо ComposeView як місток між класичними View та декларативним UI.

class ComposePresentation(
    context: Context,
    display: Display,
    private val lifecycleOwner: LifecycleOwner,
    private val viewModelStoreOwner: ViewModelStoreOwner,
    private val savedStateRegistryOwner: SavedStateRegistryOwner
) : Presentation(context, display) {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val composeView = ComposeView(context).apply {
            // Встановлюємо власників життєвого циклу (це критично для роботи Compose)
            setViewTreeLifecycleOwner(lifecycleOwner)
            setViewTreeViewModelStoreOwner(viewModelStoreOwner)
            setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner)
            // Встановлюємо стратегію очищення (важливо для вікон без Activity)
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)

            setContent {
                MaterialTheme {
                    Surface(
                        modifier = Modifier.fillMaxSize(),
                        color = MaterialTheme.colorScheme.background
                    ) {
                        Column(
                            modifier = Modifier.fillMaxSize().padding(8.dp),
                            verticalArrangement = Arrangement.Top,
                            horizontalAlignment = Alignment.CenterHorizontally
                        ) {
                            Text(
                                text = "Це Compose на віртуальному екрані",
                                style = MaterialTheme.typography.headlineMedium
                            )
                            Spacer(modifier = Modifier.height(16.dp))
                            var clickCounter by remember { mutableIntStateOf(0) }
                            Button(onClick = onClick = { clickCounter = clickCounter.inc() }) {
                                Text("Натисни мене")
                            }
                            Slider(
                                value = 0.5f,
                                onValueChange = { /* Дія */ },
                                modifier = Modifier.padding(horizontal = 16.dp)
                             )
                        }
                    }
                }
            }
        }

        // Встановлюємо ComposeView як основний контент презентації
        setContentView(
            composeView,
            ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
        )
    }
}

Для стандартних View наявність LifecycleOwner не є обов’язковою. Проте Compose не може функціонувати без доступу до життєвого циклу, сховища ViewModel та реєстру станів. Тому ми використаємо нашу Activity як донора цих параметрів і передамо їх через фабрику в конструктор презентації.

class ComposePresentationFactory(
    private val lifecycleOwner: LifecycleOwner,
    private val viewModelStoreOwner: ViewModelStoreOwner,
    private val savedStateRegistryOwner: SavedStateRegistryOwner
) : PresentationFactory {
    override fun create(
        displayContext: Context,
        display: Display,
    ): Presentation =
        ComposePresentation(
            displayContext,
            display,
            lifecycleOwner,
            viewModelStoreOwner,
            savedStateRegistryOwner)
}
...
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        val renderer = BoxRenderer(
            virtualDisplayCtrl = VirtualDisplayController(
                    context = this@MainActivity,
                    presentationFactory = ComposePresentationFactory(
                        lifecycleOwner = this,
                        viewModelStoreOwner = this,
                        savedStateRegistryOwner = this
                    )
                )
    )

Цього разу на екрані маємо побачити щось таке:


 

Передача евентів до віртуального дисплею

Спробуємо не лише рендерити контент, а й взаємодіяти з ним, передаючи події дотику (touch events) до UI на віртуальному дисплеї. Для цього модифікуємо VirtualDisplayController, додавши метод sendEvent:

    ...
    fun sendEvent(event: MotionEvent, x: Float, y: Float) {
        presentation?.let {
            // Створюємо нову подію, масштабуючи відносні координати (0..1) 
            // до реальних пікселів віртуального дисплея
            val scaledEvent = MotionEvent.obtain(
                event.downTime,
                event.eventTime,
                event.action,
                x * width,
                y * height,
                event.metaState
            )
            // Відправляємо подію безпосередньо у вікно презентації
            it.window?.decorView?.dispatchTouchEvent(scaledEvent)
            scaledEvent.recycle()
        }
    }

В MainActivity запустимо корутину, яка імітуватиме натискання (цикл Down/Up) кожні три секунди:

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        lifecycleScope.launch {
            while (isActive) {
                delay(3000)
                val downTime = SystemClock.uptimeMillis()
                val eventTime = SystemClock.uptimeMillis()
                // Емуляція натискання (ACTION_DOWN)
                val downEvent = MotionEvent.obtain(
                    downTime, downTime, MotionEvent.ACTION_DOWN, 0f, 0f, 0
                )
                displayController.sendEvent(
                    event = downEvent,
                    x = 0.5f,
                    y = 0.3f
                )
                downEvent.recycle()

                // Емуляція відпускання (ACTION_UP) через 50 мс
                val upEvent = MotionEvent.obtain(
                    downTime, downTime + 50, MotionEvent.ACTION_UP, 0f, 0f, 0
                )
                displayController.sendEvent(
                    event = upEvent,
                    x = 0.5f,
                    y = 0.3f
                )
                upEvent.recycle()
            }
        }
        ...
    }

Як результат, напис на кнопці почне змінюватися при кожному спрацьовуванні циклу. Також на відео можна помітити характерний ripple-ефект:


 

Коментарі

Популярні дописи з цього блогу

Огляд DC-DC Step-down Buck перетворювачів

ESP8266 модуль з OLED екраном (HW-364A)

Модуль PD тригер IP2721 на 15 та 20 вольт