Initial commit

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-09-01 15:04:28 +02:00
commit 835183a0c3
148 changed files with 8256 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
group = "com.hanskokx.custom_cursor"
plugins {
id("com.android.library")
}
val agpMajor =
com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.substringBefore('.').toInt()
// AGP 9 compiles Kotlin natively (built-in Kotlin); only apply the Kotlin
// Gradle Plugin on older AGP hosts that need it.
if (agpMajor < 9) {
apply(plugin = "org.jetbrains.kotlin.android")
}
android {
namespace = "com.hanskokx.custom_cursor"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
defaultConfig {
minSdk = maxOf(flutter.minSdkVersion, 24)
}
}
project.extensions.configure(
org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension::class.java
) {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
}
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true
+2
View File
@@ -0,0 +1,2 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,135 @@
// Copyright (c) 2026 Hans Kokx.
// SPDX-License-Identifier: MIT
package com.hanskokx.custom_cursor
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.view.PointerIcon
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import kotlin.math.roundToInt
/** CustomCursorPlugin */
class CustomCursorPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
private var channel: MethodChannel? = null
private var activity: Activity? = null
private val cursors = mutableMapOf<String, PointerIcon>()
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger, CHANNEL)
channel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel?.setMethodCallHandler(null)
channel = null
cursors.clear()
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
onAttachedToActivity(binding)
}
override fun onDetachedFromActivityForConfigChanges() {
onDetachedFromActivity()
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"registerCursor" -> handleRegisterCursor(call)
"setCursor" -> handleSetCursor(call)
"systemCursorScale" -> result.success(1.0)
"registerScaleObserver" -> result.success(null)
"systemDirection" -> result.success("ltr")
"registerDirectionObserver" -> result.success(null)
"resetCursor" -> handleResetCursor()
else -> result.notImplemented()
}
}
private fun handleRegisterCursor(call: MethodCall) {
val args = call.arguments as? Map<*, *> ?: return
val name = args["name"] as? String ?: return
val pngBytes = args["png"] as? ByteArray ?: return
val hotspotX = (args["hotspotX"] as? Number)?.toInt() ?: 0
val hotspotY = (args["hotspotY"] as? Number)?.toInt() ?: 0
val original = BitmapFactory.decodeByteArray(pngBytes, 0, pngBytes.size) ?: return
val density = activity?.resources?.displayMetrics?.density ?: 1.0f
val bitmap = scaleByDensity(original, density)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
cursors[name] =
PointerIcon.create(
bitmap,
(hotspotX * (bitmap.width.toFloat() / original.width)).toFloat(),
(hotspotY * (bitmap.height.toFloat() / original.height)).toFloat(),
)
}
}
private fun handleSetCursor(call: MethodCall) {
val args = call.arguments as? Map<*, *> ?: return
val name = args["name"] as? String ?: return
val icon = cursors[name]
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setPointerIcon(icon)
}
}
private fun handleResetCursor() {
cursors.clear()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setPointerIcon(null)
}
}
private fun setPointerIcon(icon: PointerIcon?) {
val views = mutableListOf<View>()
val decor = activity?.window?.decorView ?: return
collectHostViews(decor, views)
for (view in views) {
view.pointerIcon = icon
}
}
private fun collectHostViews(view: View, out: MutableList<View>) {
val name = view.javaClass.name
if (name == "io.flutter.embedding.android.FlutterView" || name.endsWith("SurfaceView")) {
out.add(view)
}
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
collectHostViews(view.getChildAt(i), out)
}
}
}
private fun scaleByDensity(bitmap: Bitmap, density: Float): Bitmap {
val width = (bitmap.width * density).roundToInt()
val height = (bitmap.height * density).roundToInt()
if (width == bitmap.width && height == bitmap.height) return bitmap
return Bitmap.createScaledBitmap(bitmap, width, height, true)
}
companion object {
private const val CHANNEL = "custom_cursor"
}
}