Давайте будем честными… мы любим 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-разработке.
Хорошего дня!!!