Давайте будем честными… мы любим Kotlin и его замечательную функцию под названием «Расширения Kotlin»; они очень мощные, я уверен, вы уже это знаете… Но, конечно, если вы никогда не слышали что-либо о расширениях Kotlin, сначала перейдите к официальной документации:

https://kotlinlang.org/docs/extensions.html

В этой статье я поделюсь некоторыми функциями расширения (и некоторыми функциями уровня пакета), которые я использую в своем текущем рабочем процессе, или некоторыми, которые я написал в прошлых проектах.

Во всех примерах мы будем предполагать, что у нас есть приложение, которое взаимодействует с REST API, и это приложение управляет списком контактов. Пользовательский интерфейс может отображать список пользователей, и каждого пользователя можно редактировать/удалять, а также добавлять нового.

Расширения, связанные с представлениями

Начнем с простых…

Примечание: если вы используете Jetpack Compose, возможно, следующие функции будут не совсем полезны, но если вы все еще работаете с XML или у вас есть какой-то устаревший проект, вам будет интересно.

Управление видимостью

fun View.show() {
    this.visibility = View.VISIBLE
}

fun View.hide() {
    this.visibility = View.GONE
}

fun View.invisible() {
    this.visibility = View.INVISIBLE
}

Довольно много пояснений, но давайте посмотрим на пример. Предположим, что у нас есть контейнер типа ConstraintLayout для полей пользовательской формы и представление ProgressIndicator, которое будет отображаться, пока API возвращает результат.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    
    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/container"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />
    
    <com.google.android.material.progressindicator.CircularProgressIndicator
        android:id="@+id/progress"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

</androidx.constraintlayout.widget.ConstraintLayout>
val container: ConstraintLayout = viewBinding.container
val loading: CircularProgressIndicator = viewBinding.progress

// ...

// retrieving data from a viewmodel
viewModel.users.observe(this) {
    when (it) {
        is UiState.Loading -> {
            container.hide()
            loading.show()
        }
        is UiState.Success -> {
            container.show()
            loading.hide()
        }
        // other conditions ...
    }
}

…и тесты?

Конечно, мы можем написать функцию, подтверждающую видимость представлений, например:

import org.junit.jupiter.api.Assertions.assertEquals

fun assertVisible(v: View) = assertEquals(v.visibility, View.VISIBLE)
fun assertNotVisible(v: View) = assertEquals(v.visibility, View.GONE)

Установка Drawable в объект ImageView

Если вы хотите назначить рисуемый объект с некоторым цветом в ImageView в одном предложении или только цветом, вы можете написать что-то вроде этого:

/**
 * Configures an [ImageView] passing a [Drawable] and an ID of a color resource
 *
 * @param drawable A [Drawable] to set to the [ImageView]
 * @param colorResId A resource ID from the desired color
 */
internal fun ImageView.setDrawableWithColor(
    drawable: Drawable?,
    @ColorRes colorResId: Int
) {
    setImageDrawable(drawable)
    setColor(colorResId)
}

internal fun ImageView.setColor(
    @ColorRes colorResId: Int
) = ImageViewCompat.setImageTintList(
    this,
    ColorStateList.valueOf(
        ResourcesCompat.getColor(resources, colorResId, null)
    )
)

Получение всех дочерних представлений некоторого заданного представления

/**
 * Get all children of a [View]
 *
 * @return A [List] of [View] with the children of a given [View]
 */
fun View.getAllChildren(): List<View> {
    val childrenList: MutableList<View> = mutableListOf()
    val viewGroup = this as? ViewGroup
    // null-check because if this is not an instance of ViewGroup,
    // val viewGroup will be null
    viewGroup?.let {
        for (i in 0 until viewGroup.childCount) {
            childrenList.add(viewGroup.getChildAt(i))
        }
    }
    return childrenList
}

Управление включенным состоянием для дочерних представлений данного представления

Что, если нам нужно одновременно управлять enabled свойством для нескольких представлений? Например, у нас есть пользовательская форма, выполняющая POST для сохранения данных, и есть ненавязчивый индикатор выполнения (например, LinearProgressIndicator), и мы хотим отключить все представления, чтобы избежать любого взаимодействия.

Хорошо, используя предыдущую функцию, вы можете сделать что-то вроде этого:

/**
 * Change view children state to the value in parameter
 *
 * @param state The [Boolean] state to set
 */
fun View.setChildrenEnabledState(state: Boolean) {
    this.getAllChildren().forEach { it.isEnabled = state }
}

Настройка видимости представления на основе объекта, допускающего значение NULL

Хорошо, это звучит странно, но это очень полезно, когда вам нужно управлять видимостью представления на основе обнуляемости некоторого объекта с данными для заполнения представления. Пойдем:

/**
 * Takes an object of type [T], makes a null-check and if it's not null, executes [block] with [V]
 * as the receiver, and [T] as implicit param. **If [data] is null, the view will be hidden.**
 * @param data An object with useful data to pass to the view
 * @param view The view to configure
 * @param block A function to execute in the context of [view] with param [T]
 */
fun <T, V : View> configureViewWithNullableData(data: T?, view: V, block: V.(T) -> Unit) {
    with(view) {
        data?.let { block(it) } ?: this.hide()
    }
}

Давайте посмотрим пример:

val user1 = User(
    id = 1234567890,
    firstName = "John",
    lastName = "Doe",
    age = 31,
    phones = listOf(
       // list of phones
    )
)

configureViewWithNullableData(user1.phones, viewBinding.cardPhones) {
    // populates the view with data in phones
}

val user2 = User(
    id = 1234567890,
    firstName = "Tommy",
    lastName = "Shelby",
    age = 40,
    phones = null
)

configureViewWithNullableData(user2.phones, viewBinding.cardPhones) {
    // this code block will never be executed;
    // instead, viewBinding.cardPhones will be hidden.
}

Программное добавление дочернего представления в ConstraintLayout

Иногда вы создаете представления по коду, и вам нужно добавить их в файл ConstraintLayout. Чтобы упростить это, давайте посмотрим на следующие расширения:

// adding some useful imports to simplify the code

import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.MATCH_CONSTRAINT
import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.WRAP_CONTENT
import androidx.constraintlayout.widget.ConstraintSet
import androidx.constraintlayout.widget.ConstraintSet.START
import androidx.constraintlayout.widget.ConstraintSet.END
import androidx.constraintlayout.widget.ConstraintSet.TOP
import androidx.constraintlayout.widget.ConstraintSet.BOTTOM
import androidx.constraintlayout.widget.ConstraintSet.PARENT_ID

/**
 * Add a view into a ConstraintLayout, below of the last view (like a stack).
 *
 * @param child A [View] to add
 */
fun ConstraintLayout.addChildOnStack(
    child: View,
    layoutParams: LayoutParams = LayoutParams(MATCH_CONSTRAINT, WRAP_CONTENT)
) {
    // get all children of this, and the last child
    val children = this.getAllChildren()
    val lastChild = if (children.isNotEmpty()) children.last() else null

    // check if no exists lastChild, then attach child to parent in top constraint
    val topParentId = lastChild?.id ?: PARENT_ID
    val parentSide = if (lastChild == null) TOP else BOTTOM

    // generate an ID for child, and add child into this
    child.id = View.generateViewId()
    this.addView(child, layoutParams)

    // create constraints to child
    val constraintSet = ConstraintSet()
    constraintSet.clone(this)
    constraintSet.connect(child.id, TOP, topParentId, parentSide)
    constraintSet.connect(child.id, START, PARENT_ID, START)
    constraintSet.connect(child.id, END, PARENT_ID, END)
    constraintSet.applyTo(this)
}

/**
 * Add a view into a ConstraintLayout, at the right of the last view (like a horizontal row).
 *
 * @param child A [View] to add
 * @param layoutParams (optional) A [LayoutParams] object with some previous configurations
 */
internal fun ConstraintLayout.addChildOnRow(
   child: View,
   layoutParams: LayoutParams = LayoutParams(MATCH_CONSTRAINT, WRAP_CONTENT).also {
       it.horizontalChainStyle = LayoutParams.CHAIN_SPREAD
   }
) {
    // get all children of this, and the last child
    val children = this.getAllChildren()
    val lastChild = if (children.isNotEmpty()) children.last() else null

    // check if doesn't exists lastChild, then attach child to parent view in start constraint;
    // otherwise, it attaches the view at end constraint of lastChild.
    val topParentId = lastChild?.id ?: PARENT_ID
    val parentSide = if (lastChild == null) START else END

    // generate an ID for child, and add child into this
    child.id = View.generateViewId()
    this.addView(child, layoutParams)

    // create constraints to child
    val constraintSet = ConstraintSet()
    constraintSet.clone(this)
    constraintSet.connect(child.id, TOP, PARENT_ID, TOP)
    constraintSet.connect(child.id, BOTTOM, PARENT_ID, BOTTOM)
    constraintSet.connect(child.id, START, topParentId, parentSide)
    constraintSet.connect(child.id, END, PARENT_ID, END)
    if (lastChild != null) constraintSet.connect(lastChild.id, END, child.id, START)
    constraintSet.applyTo(this)
}

Хорошо, давайте разберем приведенный выше код.

Оба метода получают 2 параметра: View для добавления и необязательный объект LayoutParams (в большинстве случаев вы хотите использовать значение по умолчанию для этого параметра).

В 1-м случае метод addChildOnStack добавляет представления как «стек» (все новые представления будут добавляться внизу):

Во 2-м случае addChildOnRow добавляет представления «рядом», от начала до конца (зависит от макета, LTR или RTL):

Установка слушателя в TextView для изменения текста

/**
 * Executes a function inside a [TextWatcher] [onTextChanged] method.
 */
internal fun TextView.onTextChanged(block: (currentText: String) -> Unit) {
    textWatcher = object : TextWatcher {
        override fun afterTextChanged(s: Editable?) {
            /* Nothing to do */
        }

        override fun beforeTextChanged(
            s: CharSequence?,
            start: Int,
            count: Int,
            after: Int
        ) {
            /* Nothing to do */
        }

        override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
            block(s?.toString().orEmpty())
        }
    }
}

Предыдущее расширение делает более читаемым и лаконичным задание этого поведения для TextView. Его можно использовать следующим образом:

class UserFormActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // some custom behaviour...
        
        viewBinding.tvUserName.onTextChanged {
            viewModel.validateInput(it)
        }
    }

}

Для экранов (Активность/Фрагмент)

Получение параметров запроса из намерения

/**
 * Get a query parameter from the Uri data in the intent,
 * and an empty string if the data or value is null
 */
fun Activity.deeplinkParam(key: String) = intent.data?.getQueryParameter(key) ?: ""

И затем вы можете использовать его следующим образом:

class UserFormActivity : AppCompatActivity() {
    
    private val userId by lazy { deeplinkParam("user_id") }

    // some other private fields declared...

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // some custom behaviour...
        viewModel.getUserCurrentData(userId)
    }

}

Добавление фрагмента

Когда дело доходит до добавления нового экземпляра фрагмента, мы можем использовать расширение, подобное следующему:

/**
 * Add a new fragment with a [FragmentManager]
 * @param fragment A [Fragment] to add
 * @param containerView A [FrameLayout] that must contains the fragment
 */
fun FragmentManager.addNewFragment(fragment: Fragment, containerView: FrameLayout) {
    if (containerView.id == ConstraintLayout.NO_ID) containerView.id = View.generateViewId()
    val transaction = beginTransaction()
    transaction.add(containerView.id, fragment)
    transaction.commit()
}

И наш код будет более лаконичным и читабельным:

// in any place inside an Activity

// adding a fragment
supportFragmentManager.addNewFragment(
    fragment = UserAddressFragment(),
    containerView = viewBinding.addressContainer.id
)

Разное

Чтение JSON и разбор в какой-то класс

Если у вас есть необработанный файл JSON и вам нужно преобразовать весь контент в класс или набор классов, вы можете использовать что-то вроде этого:

/**
 * Open a raw resource according to [resId] parameter and returns a [T] object.
 */
internal inline fun <reified T> Context.getJsonFromRawResource(@RawRes resId: Int): T {
    try {
        val rawResource = resources.openRawResource(resId)
        val buffer = ByteArray(rawResource.available())
        rawResource.read(buffer)
        val json = String(buffer)
        return Gson().fromJson(json, T::class.java)
    } catch (e: Resources.NotFoundException) {
        throw e
    } catch (e: JsonSyntaxException) {
        throw JsonSyntaxException("Error reading JSON: ${e.message}", e)
    }
}

Например, предположим, что у вас есть необработанный файл JSON (в res/raw/api_mock.json) для получения группы пользователей (вам нужно смоделировать REST API, потому что он еще не закончен, но вы знаете контракт):

{
  "users": [
    {
      "id": 123456,
      "first_name": "John",
      "last_name": "Doe",
      "phones": [
        {
          "id": 6543218765,
          "type": "personal",
          "number": "55512345678"
        },
        {
          "id": 6543218700,
          "type": "work",
          "number": "55512345222"
        }
      ]
    },
    {
      "id": 123459,
      "first_name": "Thommy",
      "last_name": "Shelby",
      "phones": []
    }
  ]
}

… и следующие классы DTO:

data class User(
    @SerializedName("id")
    val id: Long,

    @SerializedName("first_name")
    val firstName: String,

    @SerializedName("last_name")
    val lastName: String,

    @SerializedName("phones")
    val phones: List<Phone>,
)

data class Phone(
    @SerializedName("id")
    val id: Long,

    @SerializedName("type")
    val id: String,

    @SerializedName("number")
    val number: String,
)

Вы можете использовать это расширение следующим образом:

class MockDataSource(context: Context) : DataSource {
    override fun getUsers(): List<User> {
        return context.getJsonFromRawResource<List<User>>(R.raw.api_mock)
    }
}

Конечно, если вы объявите переменную с явным типом для хранения результата, это может быть проще:

val users: List<User> = context.getJsonFromRawResource(R.raw.api_mock)

Обновите LiveData из модели представления

Если вы все еще используете LiveData, может оказаться полезным следующее расширение. Если у вас есть набор моделей представления, и все они являются производными от «базовой» модели представления, вы можете использовать что-то вроде этого:

class BaseViewModel : ViewModel() {
    infix fun <T> MutableLiveData<UiState<T>>.updateValueWith(call: suspend () -> T) {
        viewModelScope.launch {
            try {
                value = UiState.Loading()
                val result = call()
                value = UiState.Success(result)
            } catch (ex: Exception) {
                value = UiState.Error(ex)
            }
        }
    }
}

sealed class UiState<T>(
    val data: T? = null,
    val exception: Exception? = null
) {
    internal class Loading<T> : UiState<T>()
    internal class Success<T>(data: T) : UiState<T>(data = data)
    internal class Error<T>(exception: Exception) : UiState<T>(exception = exception)
}

… и для каждой модели представления вы можете использовать это так:

class UsersViewModel(repository: Repository) : BaseViewModel() {
    val users = MutableLiveData<UiState<List<User>>>()

    fun getUsers() {
        users updateValueWith {
            repository.getUsers()
        }
    }
}

При этом вы избегаете записи одних и тех же обновлений в liveData для каждого метода каждой модели представления.

Спасибо, что прочитали мою первую статью!!! 🎉 🎉 🎉

Я надеюсь, что продолжу писать больше и сообщу вам больше полезной информации о Kotlin и Android-разработке.

Хорошего дня!!!