You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Flutter应用中实现添加PNG贴纸并保存图片的编辑功能?

Alright, let's walk through exactly how to build this image overlay app—from setup to core features like adding PNG stickers and saving the final image. I'll include code snippets and explain each step clearly so you can follow along.

实现步骤与详细说明

一、所需依赖包

Most functionality can be handled with Android's native APIs, but we'll use one lightweight library to simplify image loading and memory management:

  • Glide: For efficiently loading PNG assets/drawables, handling caching, and avoiding Bitmap memory leaks.
    Add this to your app-level build.gradle (Module level):
    implementation 'com.github.bumptech.glide:glide:4.16.0'
    annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
    

二、基础配置与权限

1. Permission Declarations

Add these permissions to your AndroidManifest.xml (adjust for your target Android version):

<!-- Android 12 and below -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />

<!-- Android 13+ -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

<!-- Optional: For broad storage access (only if needed for legacy devices) -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />

Note: For Android 10+, use Scoped Storage (via MediaStore) to save images instead of writing directly to public directories.

2. Core Layout Example

Create activity_main.xml with a target image area, sticker list, and save button:

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

    <!-- Target image container (supports overlaying stickers) -->
    <FrameLayout
        android:id="@+id/target_image_container"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1">

        <ImageView
            android:id="@+id/target_image"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="fitCenter"
            android:background="#f0f0f0" />

        <!-- Custom view for dragging/overlaying PNG stickers -->
        <com.yourpackage.OverlayStickerView
            android:id="@+id/overlay_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </FrameLayout>

    <!-- Horizontal list of PNG stickers -->
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/sticker_recycler"
        android:layout_width="match_parent"
        android:layout_height="120dp"
        android:padding="8dp" />

    <!-- Save button -->
    <Button
        android:id="@+id/save_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Save Composed Image"
        android:layout_margin="16dp" />
</LinearLayout>

三、Feature Implementation Steps

1. Select & Load Target Image

First, implement the ability to pick an image from the gallery, handling permission requests for different Android versions:

class MainActivity : AppCompatActivity() {
    private val PICK_IMAGE_REQUEST = 100
    private lateinit var targetImageView: ImageView
    private lateinit var overlayView: OverlayStickerView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        targetImageView = findViewById(R.id.target_image)
        overlayView = findViewById(R.id.overlay_view)

        // Trigger image picker when tapping the target image area
        targetImageView.setOnClickListener {
            checkPermissionsAndOpenPicker()
        }

        // Initialize sticker list and save button
        initStickerRecycler()
        initSaveButton()
    }

    private fun checkPermissionsAndOpenPicker() {
        val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            Manifest.permission.READ_MEDIA_IMAGES
        } else {
            Manifest.permission.READ_EXTERNAL_STORAGE
        }

        if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
            requestPermissions(arrayOf(permission), 101)
        } else {
            openImagePicker()
        }
    }

    private fun openImagePicker() {
        val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
            type = "image/*"
        }
        startActivityForResult(intent, PICK_IMAGE_REQUEST)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == PICK_IMAGE_REQUEST && resultCode == RESULT_OK && data != null) {
            val imageUri = data.data ?: return
            // Load target image into ImageView
            Glide.with(this)
                .load(imageUri)
                .into(targetImageView)
            // Pass URI to overlay view for later composition
            overlayView.setTargetImageUri(imageUri)
        }
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            openImagePicker()
        } else {
            Toast.makeText(this, "Permission required to select images", Toast.LENGTH_SHORT).show()
        }
    }
}

2. Display PNG Sticker List

Assume your PNG stickers are stored in the assets/stickers/ directory. Create a RecyclerView adapter to display them:

// Sticker RecyclerView Adapter
class StickerAdapter(
    private val stickerNames: List<String>,
    private val onStickerClicked: (String) -> Unit
) : RecyclerView.Adapter<StickerAdapter.StickerViewHolder>() {

    inner class StickerViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val stickerImage: ImageView = itemView.findViewById(R.id.sticker_item_image)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StickerViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_sticker, parent, false)
        return StickerViewHolder(view)
    }

    override fun onBindViewHolder(holder: StickerViewHolder, position: Int) {
        val stickerName = stickerNames[position]
        // Load sticker from assets using Glide
        Glide.with(holder.itemView.context)
            .load("file:///android_asset/stickers/$stickerName")
            .into(holder.stickerImage)

        holder.itemView.setOnClickListener {
            onStickerClicked(stickerName)
        }
    }

    override fun getItemCount(): Int = stickerNames.size
}

// Initialize sticker list in MainActivity
private fun initStickerRecycler() {
    val recyclerView = findViewById<RecyclerView>(R.id.sticker_recycler)
    recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
    // Get all sticker filenames from assets
    val stickerNames = assets.list("stickers")?.toList() ?: emptyList()
    recyclerView.adapter = StickerAdapter(stickerNames) { stickerName ->
        // Add selected sticker to overlay view
        overlayView.addSticker(stickerName)
    }
}

Create item_sticker.xml for each list item:

<?xml version="1.0" encoding="utf-8"?>
<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/sticker_item_image"
    android:layout_width="80dp"
    android:layout_height="80dp"
    android:layout_marginHorizontal="8dp"
    android:scaleType="fitCenter"
    android:background="#ffffff" />

3. Overlay & Drag PNG Stickers

Create a custom OverlayStickerView to handle sticker placement, dragging, and composition:

class OverlayStickerView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
    private var targetImageUri: Uri? = null
    private val stickers = mutableListOf<Sticker>()
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    private var activeStickerIndex = -1
    private var lastTouchX = 0f
    private var lastTouchY = 0f

    data class Sticker(val bitmap: Bitmap, var position: RectF)

    fun setTargetImageUri(uri: Uri?) {
        targetImageUri = uri
        invalidate()
    }

    fun addSticker(stickerName: String) {
        // Load sticker from assets
        val inputStream = context.assets.open("stickers/$stickerName")
        val bitmap = BitmapFactory.decodeStream(inputStream)
        // Place sticker at center of the view by default
        val rect = RectF(
            (width - bitmap.width) / 2f,
            (height - bitmap.height) / 2f,
            (width + bitmap.width) / 2f,
            (height + bitmap.height) / 2f
        )
        stickers.add(Sticker(bitmap, rect))
        activeStickerIndex = stickers.size - 1
        invalidate()
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        canvas ?: return
        // Draw target image first (if selected)
        targetImageUri?.let { uri ->
            val targetBitmap = Glide.with(context)
                .asBitmap()
                .load(uri)
                .submit()
                .get()
            val scaledBitmap = Bitmap.createScaledBitmap(targetBitmap, width, height, true)
            canvas.drawBitmap(scaledBitmap, 0f, 0f, paint)
            targetBitmap.recycle()
            scaledBitmap.recycle()
        }
        // Draw all stickers on top
        stickers.forEach { sticker ->
            canvas.drawBitmap(sticker.bitmap, null, sticker.position, paint)
        }
    }

    // Handle touch events for dragging stickers
    override fun onTouchEvent(event: MotionEvent?): Boolean {
        event ?: return false
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                // Check if touch is on any sticker
                activeStickerIndex = stickers.indexOfLast { sticker ->
                    sticker.position.contains(event.x, event.y)
                }
                if (activeStickerIndex != -1) {
                    val sticker = stickers[activeStickerIndex]
                    lastTouchX = event.x - sticker.position.left
                    lastTouchY = event.y - sticker.position.top
                }
            }
            MotionEvent.ACTION_MOVE -> {
                if (activeStickerIndex != -1) {
                    val sticker = stickers[activeStickerIndex]
                    // Update sticker position
                    sticker.position.left = event.x - lastTouchX
                    sticker.position.top = event.y - lastTouchY
                    sticker.position.right = sticker.position.left + sticker.position.width()
                    sticker.position.bottom = sticker.position.top + sticker.position.height()
                    invalidate()
                }
            }
        }
        return true
    }

    // Get the final composed bitmap for saving
    fun getComposedBitmap(): Bitmap? {
        targetImageUri ?: return null
        val targetBitmap = Glide.with(context)
            .asBitmap()
            .load(targetImageUri)
            .submit()
            .get()
        val composedBitmap = Bitmap.createBitmap(targetBitmap.width, targetBitmap.height, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(composedBitmap)
        canvas.drawBitmap(targetBitmap, 0f, 0f, paint)

        // Scale sticker positions to match target bitmap size
        val scaleX = targetBitmap.width.toFloat() / width.toFloat()
        val scaleY = targetBitmap.height.toFloat() / height.toFloat()
        stickers.forEach { sticker ->
            val scaledRect = RectF(
                sticker.position.left * scaleX,
                sticker.position.top * scaleY,
                sticker.position.right * scaleX,
                sticker.position.bottom * scaleY
            )
            canvas.drawBitmap(sticker.bitmap, null, scaledRect, paint)
        }
        targetBitmap.recycle()
        return composedBitmap
    }
}

4. Save the Composed Image

Implement save functionality that works with Scoped Storage (Android 10+):

// Initialize save button in MainActivity
private fun initSaveButton() {
    findViewById<Button>(R.id.save_button).setOnClickListener {
        val composedBitmap = overlayView.getComposedBitmap() ?: run {
            Toast.makeText(this, "Select a target image and add stickers first!", Toast.LENGTH_SHORT).show()
            return@setOnClickListener
        }
        saveImageToGallery(composedBitmap)
    }
}

private fun saveImageToGallery(bitmap: Bitmap) {
    val contentValues = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, "composed_${System.currentTimeMillis()}.png")
        put(MediaStore.Images.Media.MIME_TYPE, "image/png")
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/StickerComposer")
        }
    }

    val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) ?: run {
        Toast.makeText(this, "Failed to save image", Toast.LENGTH_SHORT).show()
        return
    }

    try {
        contentResolver.openOutputStream(uri)?.use { outputStream ->
            bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
            Toast.makeText(this, "Image saved to gallery!", Toast.LENGTH_SHORT).show()
        }
    } catch (e: IOException) {
        e.printStackTrace()
        Toast.makeText(this, "Save failed: ${e.message}", Toast.LENGTH_SHORT).show()
    } finally {
        bitmap.recycle()
    }
}

四、Key Notes

  • Memory Management: Always call recycle() on Bitmaps when done, or let Glide handle memory caching to avoid OutOfMemoryErrors.
  • PNG Transparency: The default Paint settings preserve alpha channels, so your PNG stickers will retain transparency.
  • Screen Adaptation: The OverlayStickerView scales sticker positions to match the target image's actual resolution, ensuring the saved image matches what's on screen.
  • Permission Compatibility: The code handles permission differences between Android versions to avoid crashes.

内容的提问来源于stack exchange,提问作者Vishnu ES

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.09 21:12:52