Changing Image Aspect Ratio in Android using Kotlin + Couroutines

Changing Image Aspect Ratio in Android using Kotlin + Couroutines

The aspect ratio of an image is a proportional relationship between its width and height, We will change aspect ratio of any image in android.

The aspect ratio of an image is a proportional relationship between its width and height. It is commonly expressed as two numbers separated by a colon, as in 16:9. This ratio is extremely important in ensuring that images do not appear distorted or stretched when displayed on different devices or within different layouts. Incorrect aspect ratios can lead to a poor visual experience for the end user, making it crucial to manage this aspect of image handling effectively.

To tackle this problem, we'll be leveraging a host of tools and technologies. Firstly, we'll use Kotlin - a statically typed programming language that is fully interoperable with Java and is now Google's preferred language for Android app development. Kotlin's clear syntax and modern features make it a great choice for building robust and efficient Android applications.

For loading and displaying images, we'll employ Glide, a powerful and highly efficient open-source media management and image-loading framework for Android. Glide simplifies the task of fetching, decoding, and displaying images, videos, and animated GIFs. It handles almost everything related to the image-loading process, such as memory and disk caching, pooling, and pre-fetching, thereby enabling smooth and fast image displays.

Additionally, we'll use PermissionX, a simplified library for managing permissions in Android.

Dependancies:

Build.gradle:

implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'
implementation 'com.guolindev.permissionx:permissionx:1.7.1'

User Interface XML's:

activity_main.xml

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

    <LinearLayout
        android:layout_width="match_parent"
        android:gravity="center"
        android:layout_height="500dp">

    <ImageView
        android:id="@+id/id_imageview"
        android:layout_width="300sp"
        android:layout_height="300sp"
        android:layout_gravity="center" />
    </LinearLayout>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:background="@color/white"
        android:paddingVertical="10dp">

        <ImageView
            android:id="@+id/id_download"
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:layout_marginHorizontal="10dp"
            android:src="@drawable/ic_baseline_arrow_circle_down_24" />

        <ImageView
            android:layout_width="40dp"
            android:id="@+id/id_share"
            android:layout_height="40dp"
            android:layout_marginHorizontal="10dp"
            android:src="@drawable/ic_baseline_share_24" />

        <ImageView
            android:id="@+id/id_aspect_ratio"
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:layout_marginHorizontal="10dp"
            android:src="@drawable/ic_baseline_aspect_ratio_24" />
        <ImageView
            android:id="@+id/id_color_picker"
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:layout_marginHorizontal="10dp"
            android:src="@drawable/ic_baseline_colorize_24" />
    <ImageView
            android:id="@+id/id_transparent"
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:layout_marginHorizontal="10dp"
            android:src="@drawable/transparent" />

    </LinearLayout>


</LinearLayout>

Lets get started to Code

Init the variables first:

class MainActivityS : AppCompatActivity() {
    lateinit var id_imageview: ImageView
    lateinit var id_aspect_ratio: ImageView
    lateinit var id_download: ImageView
    private var selectedAspectRatioOption: String = ""
    // ... other methods and properties ...
}

The selectedAspectRatioOption string holds the currently selected aspect ratio. It's initially empty, but gets updated when the user makes a selection.

override fun onCreate(savedInstanceState: Bundle?) {
    // ... initialization ...

    Glide.with(this).load(imageurl).into(id_imageview)

    id_download.setOnClickListener {
        downloadImageInternal()
    }

    id_aspect_ratio.setOnClickListener {
        showBottomSheetForAspectRatio()
    }
}

Load Images and Permission Handling

Glide is an open-source media management framework for Android that simplifies fetching, decoding, and displaying images, videos, and animated GIFs. It is well-suited to applications that require smooth scrolling and fast image loading.

In our MainActivityS, we use Glide to load an image from a URL into our ImageView as follows:

val imageurl = "https://images.unsplash.com/photo-1683997941376-d5bbecbcdae7?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=687&q=80"
Glide.with(this).load(imageurl).into(id_imageview)

The Glide.with(this) method is used to create a new Glide request, with this being the current context. load(imageurl) tells Glide to load an image from the specified URL. Finally, into(id_imageview) directs Glide to put the loaded image into the specified ImageView.

In Android, certain operations like reading or writing to the device's storage, require explicit user permissions. This is a part of Android's security model to protect user data from unauthorized access.

We use the PermissionX library in our MainActivityS to manage permissions related to external storage, which is needed when saving the downloaded image.

private fun checkPermissions() {
    PermissionX.init(this).permissions(Manifest.permission.MANAGE_EXTERNAL_STORAGE)
    // ... remaining code ...
}
.onExplainRequestReason { scope, deniedList ->
    scope.showRequestReasonDialog(
            deniedList,
            "Core fundamental are based on these permissions",
            "OK",
            "Cancel"
    )
}.onForwardToSettings { scope, deniedList ->
    scope.showForwardToSettingsDialog(
            deniedList,
            "You need to allow necessary permissions in Settings manually",
            "OK",
            "Cancel"
    )
}

Finally, we call .request to begin the request process and handle the result in the provided lambda function.

.request { allGranted, _, deniedList ->
    if (!allGranted) {
        Toast.makeText(
                this,
                "These permissions are denied: $deniedList",
                Toast.LENGTH_LONG
        ).show()
    }
}

In this block, we check if all permissions are granted (allGranted is true). If not, we show a toast message listing the permissions that were denied.

Manifest:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:requestLegacyExternalStorage="true"
        android:theme="@style/Theme.Aspect_photo_edit"
        tools:targetApi="31">
        <activity
            android:name=".MainActivitys"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <meta-data
                android:name="android.app.lib_name"
                android:value="" />
        </activity>
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>


    </application>

</manifest>

Downloading Images

The process of downloading images in our application involves creating a bitmap from the ImageView and then saving that bitmap as a JPEG file in the device's storage.

  1. Creating a Bitmap from a View:

The getBitmapFromView() function is used to create a bitmap from our ImageView:

private fun getBitmapFromView(view: ImageView): Bitmap {
    val bitmap = Bitmap.createBitmap(
            view.width,
            view.height,
            Bitmap.Config.ARGB_8888
    )
    val canvas = Canvas(bitmap)
    canvas.drawColor(Color.WHITE)
    view.draw(canvas)
    return bitmap
}

In this function, a new bitmap is created with the dimensions of the ImageView (view.width and view.height). A Canvas object is then instantiated with the new bitmap, and the ImageView is drawn onto the canvas, resulting in the ImageView's content being stored in the bitmap.

  1. Saving the Bitmap as a JPEG file:

The saveBitmapToFile() function is used to save the bitmap as a JPEG file in the device's storage:

@OptIn(DelicateCoroutinesApi::class) 
private fun saveBitmapToFile(bitmap: Bitmap, fileName: String) {
    // ... function code ...
}

In this function, we first ensure the directory where the image will be stored exists. Then, a FileOutputStream is opened for the image file. The bitmap is compressed into the JPEG format using bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream). The stream is then flushed and closed.

  1. Using a Coroutine to Perform the Save Operation:

The saving process could potentially take some time, especially for larger images. To avoid blocking the main thread and keep the app responsive, we use a coroutine to perform this operation asynchronously:

GlobalScope.launch(Dispatchers.IO) {
    // ... saving process ...
}

We use GlobalScope.launch(Dispatchers.IO) to create a new coroutine that runs on the IO dispatcher, which is optimized for disk and network IO off the main thread.

After the image is saved, we use MediaScannerConnection.scanFile() to notify the system that a new file has been created, so it immediately appears in the user's gallery.

Finally, we switch back to the main dispatcher to show a toast message indicating whether the download was successful:

withContext(Dispatchers.Main) {
    onImageDownloaded(true)
}

Implementing Aspect Ratio:

We've used a Bottom Sheet Dialog in our application to present the user with different aspect ratio options. The showBottomSheetForAspectRatio() function handles this:

private fun showBottomSheetForAspectRatio() {
    val bottomSheetView = layoutInflater.inflate(
            R.layout.bottom_sheet,
            null
    )
    val bottomSheetDialog = BottomSheetDialog(this)
    bottomSheetDialog.setContentView(bottomSheetView)
    bottomSheetDialog.show()
    setupBottomSheetOptions(bottomSheetDialog, bottomSheetView)
}

In this function, we inflate a view from R.layout.bottom_sheet and then create a BottomSheetDialog object. The inflated view is set as the content of the dialog, and then the dialog is shown to the user.

  1. Changing the Aspect Ratio of the ImageView Based on User's Choice:

The setupBottomSheetOptions() function is used to handle the user's selection from the bottom sheet options:

private fun setupBottomSheetOptions(bottomSheetDialog: BottomSheetDialog, bottomSheetView: View) {
    // ... code to setup onClick listeners for each option ...
}

In this function, we first find each aspect ratio option in the bottom sheet view and then set an OnClickListener for each. When an option is clicked, the setAspectRatio() function is called with the width and height values corresponding to the chosen aspect ratio. The bottom sheet dialog is then dismissed.

The setAspectRatio() function adjusts the ImageView's aspect ratio according to the user's selection:

private fun setAspectRatio(width: Int, height: Int, option: String) {
    val layoutParams = id_imageview.layoutParams as LinearLayout.LayoutParams
    layoutParams.width = width
    layoutParams.height = height
    id_imageview.layoutParams = layoutParams
    id_imageview.setBackgroundResource(R.color.white)
    selectedAspectRatioOption = option
}

In this function, we get the LayoutParams of the ImageView and then modify its width and height to the specified values. The ImageView's background color is set to white, and the selectedAspectRatioOption variable is updated to reflect the chosen aspect ratio.

Complete Activity Code

MainActivitys.kt:

package com.developersmarket.aspect_photo_edit

import android.Manifest
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.media.MediaScannerConnection
import android.os.Bundle
import android.os.Environment
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.bumptech.glide.Glide
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.permissionx.guolindev.PermissionX
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.io.IOException

class MainActivityS : AppCompatActivity() {
    lateinit var id_imageview: ImageView
    lateinit var id_aspect_ratio: ImageView
    lateinit var id_download: ImageView
    private var selectedAspectRatioOption: String = ""

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

        id_imageview = findViewById(R.id.id_imageview)
        id_aspect_ratio = findViewById(R.id.id_aspect_ratio)
        id_download = findViewById(R.id.id_download)

        val imageurl = "https://images.unsplash.com/photo-1683997941376-d5bbecbcdae7?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=687&q=80"
        Glide.with(this).load(imageurl).into(id_imageview)

        id_download.setOnClickListener {
            downloadImageInternal()
        }

        checkPermissions()

        id_aspect_ratio.setOnClickListener {
            showBottomSheetForAspectRatio()
        }
    }

    private fun downloadImageInternal() {
        val bitmap = getBitmapFromView(id_imageview)
        val fileName = generateFileName(selectedAspectRatioOption)

        saveBitmapToFile(
                bitmap,
                fileName
        )
    }

    private fun getBitmapFromView(view: ImageView): Bitmap {
        val bitmap = Bitmap.createBitmap(
                view.width,
                view.height,
                Bitmap.Config.ARGB_8888
        )
        val canvas = Canvas(bitmap)
        canvas.drawColor(Color.WHITE)
        view.draw(canvas)
        return bitmap
    }

    private fun generateFileName(aspectRatioOption: String): String {
        val randomName = "image_${System.currentTimeMillis()}_"
        return "${aspectRatioOption}${randomName}.jpg"
    }

    @OptIn(DelicateCoroutinesApi::class) private fun saveBitmapToFile(bitmap: Bitmap, fileName: String) {
        GlobalScope.launch(Dispatchers.IO) {
            // Create the AspectRatioApp directory inside the Download directory if it doesn't exist
            val appDirectory = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "AspectRatioApp")
            if (!appDirectory.exists()) {
                appDirectory.mkdir()
            }

            // Save the Bitmap to a file inside the AspectRatioApp directory
            val imageFile = File(appDirectory, fileName)
            try {
                val outputStream = FileOutputStream(imageFile)
                bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
                outputStream.flush()
                outputStream.close()
                // Trigger media scan for the saved image file
                MediaScannerConnection.scanFile(
                        this@MainActivityS,
                        arrayOf(imageFile.absolutePath),
                        null,
                        null
                )
                withContext(Dispatchers.Main) {
                    onImageDownloaded(true)
                }
            } catch (e: IOException) {
                e.printStackTrace()
                withContext(Dispatchers.Main) {
                    onImageDownloaded(false)
                }
            }
        }

    }

    private fun onImageDownloaded(success: Boolean) {
        runOnUiThread {
            if (success) {
                Toast.makeText(
                        this,
                        "Image downloaded successfully",
                        Toast.LENGTH_SHORT
                ).show()
            } else {
                Toast.makeText(
                        this,
                        "Failed to download image",
                        Toast.LENGTH_SHORT
                ).show()
            }
        }
    }

    private fun checkPermissions() {
        PermissionX.init(this).permissions(Manifest.permission.MANAGE_EXTERNAL_STORAGE).onExplainRequestReason { scope, deniedList ->
            scope.showRequestReasonDialog(
                    deniedList,
                    "Core fundamental are based on these permissions",
                    " OK",
                    "Cancel"
            )
        }.onForwardToSettings { scope, deniedList ->
            scope.showForwardToSettingsDialog(
                    deniedList,
                    "You need to allow necessary permissions in Settings manually",
                    "OK",
                    "Cancel"
            )
        }.request { allGranted, _, deniedList ->
            if (!allGranted) {
                Toast.makeText(
                        this,
                        "These permissions are denied: $deniedList",
                        Toast.LENGTH_LONG
                ).show()
            }
        }
    }

    private fun showBottomSheetForAspectRatio() {
        val bottomSheetView = layoutInflater.inflate(
                R.layout.bottom_sheet,
                null
        )
        val bottomSheetDialog = BottomSheetDialog(this)
        bottomSheetDialog.setContentView(bottomSheetView)
        bottomSheetDialog.show()

        setupBottomSheetOptions(bottomSheetDialog,bottomSheetView)
    }

    private fun setupBottomSheetOptions(bottomSheetDialog: BottomSheetDialog,bottomSheetView: View) {

        val option_facebook_1_1 = bottomSheetView.findViewById<TextView>(R.id.option_facebook_1_1)
        val option_facebook_4_5 = bottomSheetView.findViewById<TextView>(R.id.option_facebook_4_5)
        val option_instagram_story_9_16 = bottomSheetView.findViewById<TextView>(R.id.option_instagram_story_9_16)
        val option_instagram_1_1 = bottomSheetView.findViewById<TextView>(R.id.option_instagram_1_1)
        val option_twitter_16_9 = bottomSheetView.findViewById<TextView>(R.id.option_twitter_16_9)

        option_facebook_1_1.setOnClickListener {
            setAspectRatio(
                    1080,
                    1080,
                    "facebook_1_1"
            )
            bottomSheetDialog.dismiss()
        }

        option_facebook_4_5.setOnClickListener {
            setAspectRatio(
                    1200,
                    1500,
                    "facebook_4_5"
            )
            bottomSheetDialog.dismiss()
        }

        option_instagram_story_9_16.setOnClickListener {
            setAspectRatio(
                    1080,
                    1920,
                    "instagram_story_9_16"
            )
            bottomSheetDialog.dismiss()
        }

        option_twitter_16_9.setOnClickListener {
            setAspectRatio(
                    1200,
                    675,
                    "twitter_16_9"
            )
            bottomSheetDialog.dismiss()
        }

        option_instagram_1_1.setOnClickListener {
            setAspectRatio(
                    1080,
                    1080,
                    "instagram_1_1"
            )
            bottomSheetDialog.dismiss()
        }
    }

    private fun setAspectRatio(width: Int, height: Int, option: String) {
        val layoutParams = id_imageview.layoutParams as LinearLayout.LayoutParams
        layoutParams.width = width
        layoutParams.height = height
        id_imageview.layoutParams = layoutParams
        id_imageview.setBackgroundResource(R.color.white)
        selectedAspectRatioOption = option
    }

    companion object {
        private const val TAG = "MainActivity"
    }
}

Output

Source Code:

https://github.com/saurabhthesuperhero/aspect_photo_edit

if you find Something isnt working do check imports properly sometime wrong imports cause all the problems, yet all the best, and thanks for reading.

Did you find this article valuable?

Support saurabh jadhav by becoming a sponsor. Any amount is appreciated!