Русский
Русский
English
Статистика
Реклама

Binding

Учим HostBinding работать с Observable

26.02.2021 18:20:16 | Автор: admin

Как и многие другие Angular-разработчики, я мирился с одним ограничением. Если мы хотим использовать Observable в шаблоне, мы можем взять знакомый всем async пайп:

<button [disabled]=isLoading$ | async>

Но его нельзя применить к @HostBinding. Давным-давно это было возможно по ошибке, но это быстро исправили:

@Directive({  selector: 'button[my-button]'  host: {    '[disabled]': '(isLoading$ | async)'  }})export class MyButtonDirective {

Все потому, что хост байндинг относится к родительскому view и в нем этот пайп может быть не подключен. Это довольно желанная фича. Давайте посмотрим, как мы можем ее реализовать, пока нет официального решения.

Как работает асинхронный байндинг?

Байндинг работает с обычными данными. Когда у нас есть Observable на этом этапе необходимо покинуть реактивный мир. Нам нужно подписаться на поток, запускать проверку изменения по каждому значению и отписаться, когда поток больше не нужен. Примерно это за нас и делает async пайп. И это то, что ложится на наши плечи, когда мы хотим забайндить какие-то реактивные данные на хост.

Зачем это может понадобиться?

Мы часто работаем с RxJS в Angular. Большинство наших сервисов построены на Observable-модели. Вот пара примеров, где возможность завязываться на реактивные данные в @HostBinding была бы полезна:

  • Перевод атрибутов на другой язык. Если мы хотим сделать динамическое переключение языка в приложении мы будем использовать Observable. При этом обновлять ARIA-атрибуты, title или alt для изображений довольно непросто.

  • Изменение класса или стилей. Observable-сервис может управлять размером или трансформацией через изменение стилей хоста. Или, например, мы можем использовать реактивный IntersectionObserver для применения класса к sticky-шапке в таблице:

  • Изменение полей и атрибутов. Иногда мы хотим завязаться на BreakpointObserver для обновления placeholder или на сервис загрузки данных для выставления disable на кнопке.

  • Произвольные строковые данные, хранимые в data-атрибутах. В моей практике для них тоже иногда используются Observable-сервисы.

В Taiga UI библиотеке, над которой я работаю, есть несколько инструментов, чтобы сделать этот процесс максимально декларативным:

import {TuiDestroyService, watch} from '@taiga-ui/cdk';import {Language, TUI_LANGUAGE} from '@taiga-ui/i18n';import {Observable} from 'rxjs';import {map, takeUntil} from 'rxjs/operators';@Component({   selector: 'my-comp',   template: '',   providers: [TuiDestroyService],})export class MyComponent {   @HostBinding('attr.aria-label')   label = '';   constructor(       @Inject(TUI_LANGUAGE) language$: Observable<Language>,       @Inject(TuiDestroyService) destroy$: Observable<void>,       @Inject(ChangeDetectorRef) changeDetectorRef: ChangeDetectorRef,   ) {       language$.pipe(           map(getTranslation('label')),           watch(changeDetectorRef),           takeUntil(destroy$),       ).subscribe();   }}

Как видите, тут довольно много кода просто чтобы реализовать байндинг одного значения. Как было бы здорово, если бы можно было просто написать:

@HostBinding('attr.aria-label')readonly label$ = this.translations.get$('label');

Это потребует серьезных доработок со стороны команды Angular. Но мы можем использовать хитрый трюк в рамках публичного API, чтобы заставить это работать!

Event-плагины спешат на помощь!

Мы не можем добавить свою логику к байндингу на хост. Но мы можем сделать это для @HostListener! Я уже писал статью на эту тему. Прочитайте ее, если хотите узнать, как добавить декларативные preventDefault/stopPropagation и оптимизировать циклы проверки изменений. Если кратко Angular позволяет добавлять свои сервисы для обработки событий. Подходящий сервис выбирается с помощью имени события. Давайте перепишем код следующим образом:

@HostBinding('$.aria-label.attr')@HostListener('$.aria-label.attr')readonly label$ = this.translations.get$('label');

Выглядит странно пытаться решить задачу @HostBinding через @HostListener но читайте дальше и вы всё увидите.

Мы будем использовать $ в качестве индикатора в имени события. Модификатор .attr добавим в конец, а не в начало. Иначе регулярное выражение в Angular решит, что мы байндим строковый атрибут.

У плагинов для обработки событий есть доступ к элементу, имени события и функции-обработчику. Последний аргумент для нас бесполезен, так как это обертка, созданная компилятором. Так что нам нужно как-то передать наш Observable через элемент. Вот тут-то нам и пригодится @HostBinding. Мы положим Observable в поле с тем же именем, и тогда у нас будет доступ к нему внутри плагина:

addEventListener(element: HTMLElement, event: string): Function {   element[event] = EMPTY;   const method = this.getMethod(element, event);   const sub = this.manager       .getZone()       .onStable.pipe(           take(1),           switchMap(() => element[event]),       )       .subscribe(method);   return () => sub.unsubscribe();}

Компилятор Angular

Посмотрим на этот код повнимательнее. Первая строка может вас смутить. Хоть мы и можем назначать произвольные поля на элементы, Angular попытается их провалидировать:

Возможно, вы видели такое раньшеВозможно, вы видели такое раньше

Плагины хороши тем, что подписка на события происходит раньше разрешения байндингов. Благодаря первой строке Angular считает, что у элемента присутствует это свойство. Дальше нам нужно убедиться, что Observable уже на месте ведь на момент подписки его еще нет. Хорошо, что у нас есть доступ до NgZone и мы можем дождаться ее стабилизации, прежде чем запросить свойство элемента.

NgZone испускает onStable когда не осталось больше микро- и макрозадач в очереди. Для нас это означает, что Angular завершил цикл проверки изменений и все байндинги обновлены.

А отписку за нас сделает сам Angular достаточно вернуть функцию, прерывающую стрим.

Этого хватит, чтобы код заработал в JIT, AOT же более щепетилен. Мы добавили несуществующее поле во время выполнения, но AOT желает знать про него на этапе компиляции. До тех пор, пока эта задача не будет закрыта, мы не можем создавать свои списки разрешенных полей. Поэтому нам придется добавить NO_ERRORS_SCHEMA в модуль с подобным байндингом. Это может звучать страшно, но все, что эта схема делает, перестает проверять, есть ли поле у элемента при байндинге. Кроме того, если у вас WebStorm, вы продолжите видеть предупреждение:

Это сообщение не мешает сборкеЭто сообщение не мешает сборке

Также AOT требует реализации Callable-интерфейса для использования @HostListener. Мы можем имитировать его с помощью простой функции, сохранив оригинальный тип:

function asCallable<T>(a: T): T & Function {    return a as any;}

Итоговая запись:

@HostBinding('$.aria-label.attr')@HostListener('$.aria-label.attr')readonly label$ = asCallable(this.translations.get$('label'));

Другой вариант вовсе отказаться от @HostBinding ведь нам надо назначить его лишь один раз. Если ваш стрим приходит из DI, что происходит довольно часто, можно создать FactoryProvider. В него можно передать ElementRef и назначить поле в нем:

export const TOKEN = new InjectionToken<Observable<boolean>>("");export const PROVIDER = {  provide: TOKEN,  deps: [ElementRef, IntersectionObserverService],  useFactory: factory,}export function factory(  { nativeElement }: ElementRef,  entries$: Observable<IntersectionObserverEntry[]>): Observable<boolean> {  return nativeElement["$.class.stuck"] = entries$.pipe(map(isIntersecting));}

Теперь достаточно будет оставить только @HostListener. Его даже можно написать прямо в декораторе класса:

@Directive({  selector: "table[sticky]",  providers: [    IntersectionObserverService,    PROVIDER,  ],  host: {    "($.class.stuck)": "stuck$"  }})export class StickyDirective {  constructor(@Inject(TOKEN) readonly stuck$: Observable<boolean>) {}}

Приведенный выше пример можно увидеть вживую на StackBlitz. В нем IntersectionObserver используется для задания тени на sticky-шапке таблицы:

Обновление полей

В коде вы видели вызов getMethod. Байндинг в Angular работает не только на атрибутах и полях, но и на классах и стилях. Нам тоже нужно реализовать такую возможность. Для этого разберем имя нашего псевдособытия, чтобы понять, что же делать со значениями из потока:

private getMethod(element: HTMLElement, event: string): Function {   const [, key, value, unit = ''] = event.split('.');   if (event.endsWith('.attr')) {       return v => element.setAttribute(key, String(v));   }   if (key === 'class') {       return v => element.classList.toggle(value, !!v);   }   if (key === 'style') {       return v => element.style.setProperty(value, `${v}${unit}`);   }   return v => (element[key] = v);}

Никакой мудреной логики. На этом все, осталось только зарегистрировать плагин в глобальных провайдерах:

{    provide: EVENT_MANAGER_PLUGINS,    useClass: BindEventPlugin,    multi: true,}

Это небольшое дополнение способно существенно упростить ваш код. Нам больше не надо беспокоиться о подписке. Описанный плагин доступен в новой версии 2.1.1 нашей библиотеки @tinkoff/ng-event-plugins, а также в @taiga-ui/cdk. Поиграться с кодом можно на StackBlitz. Надеюсь, этот материал будет для вас полезным!

Подробнее..

Легкий DataBinding для Android

22.03.2021 04:10:35 | Автор: admin

Здравствуйте уважаемые читатели. Все мы любим и используем DataBinding, который представила компания Google несколько лет назад, для связи модели данных с вьюшками через ViewModel. В этой статье, хочу поделиться с вами, как можно унифицировать этот процесс с помощью языка Kotlin, и уместить создание адаптеров для RecyclerView (далее RV), ViewPager и ViewPager2 в несколько строчек кода.

Начну с того, что раньше разрабатывали кастомные адаптеры, которые под капотом создавали ViewHolder'ы, и их написание, а тем более поддержка, занимала достаточно большое количество времени. Ниже приведу пример типичного адаптера для RV:

class CustomAdapter(private val dataSet: Array<String>) : RecyclerView.Adapter<CustomAdapter.ViewHolder>() {        class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {    val textView: TextView            init {      textView = view.findViewById(R.id.textView)    }  }       override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {    val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.text_row_item, viewGroup, false)          return ViewHolder(view)  }         override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {    // Get element from your dataset at this position and replace the    viewHolder.textView.text = dataSet[position]  }        override fun getItemCount() = dataSet.size}

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

Затем появился DataBinding и большую часть по связыванию данных перекладывалась на него, но адаптеры все равно приходилось писать вручную, изменились только методы onCreateViewHolder, где вместо инфлэйтинга через LayoutInflater, использовался DataBindingUtil.inflate, а при создании вьюхолдеров данные связывались непосредственно с самой вьюшкой через ссылку на созданный объект байдинга.

class BindingViewHolder(val binding: ItemTextRowBinding) : RecyclerView.ViewHolder(binding.root)override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder {        val binding = DataBindingUtil.inflate<ItemTextRowBinding>(LayoutInflater.from(parent.context), viewType, parent, false)        val viewHolder = BindingViewHolder(binding)        return viewHolder}override fun onBindViewHolder(holder: BindingViewHolder, position: Int) {      holder.binding.setVariable(BR.item, dataSet[position])}

Выглядит уже лучше, но что если в RV, по прежнему должны отображаться элементы лайаута с разными типами данных, то такая реализация не сильно помогла решить проблему больших адаптеров. И здесь на помощь приходит аннотация BindingAdapter из библиотеки androidx.databinding. С ее помощью, можно создать универсальное решение, которое скрывает реализацию создания адаптера для RV, если использовать вспомогательный объект-конфигуратор DataBindingRecyclerViewConfig, в котором содержится ряд свойств для настройки адаптера.

В результате на свет появилась библиотека, которая называется EasyRecyclerBinding. В нее так же вошли BindingAdapters для ViewPager и ViewPager2. Теперь процесс связывания данных выглядит следующим образом:
1) В лайауте фрагмента, необходимо добавить специальные переменные, которые содержат список отображаемых моделей данных и конфигурацию, указав их атрибутами для RV, - app:items и app:rv_config.

<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools">    <data>    <variable        name="vm"        type="com.rasalexman.erb.ui.base.ExampleViewModel" />        <variable            name="rvConfig"            type="com.rasalexman.easyrecyclerbinding.DataBindingRecyclerViewConfig" />    </data>    <androidx.constraintlayout.widget.ConstraintLayout        android:id="@+id/main"        android:layout_width="match_parent"        android:layout_height="match_parent">        <androidx.recyclerview.widget.RecyclerView            android:layout_width="0dp"            android:layout_height="0dp"            app:items="@{vm.items}"            app:rv_config="@{rvConfig}"            app:layout_constraintBottom_toBottomOf="parent"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintStart_toStartOf="parent"            app:layout_constraintTop_toTopOf="parent"            tools:listitem="@layout/item_recycler"/>    </androidx.constraintlayout.widget.ConstraintLayout></layout>

ViewModel, соответственно, содержит список моделей данных для адаптера, которые должны отображаться в RV, а фрагмент конфигурацию DataBindingRecyclerViewConfig.

// названия пакетов не указаны для простоты примераclass ExampleViewModel : ViewModel() {     val items: MutableLiveData<MutableList<RecyclerItemUI>> = MutableLiveData()}data class RecyclerItemUI(    val id: String,    val title: String)

Сам лайаут для элемента списка, должен содержать ссылку на модель данных, чтобы связать их вместе.

<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools">    <data>        <variable            name="item"            type="com.rasalexman.erb.models.RecyclerItemUI" />    </data>    <androidx.constraintlayout.widget.ConstraintLayout        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:background="@android:drawable/list_selector_background">        <TextView            android:id="@+id/titleTV"            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:paddingStart="16dp"            android:paddingTop="8dp"            android:paddingEnd="16dp"            android:textColor="@color/black"            android:textSize="18sp"            android:text="@{item.title}"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintStart_toStartOf="parent"            app:layout_constraintTop_toTopOf="parent"            tools:text="Hello world" />        <TextView            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:paddingStart="16dp"            android:paddingEnd="16dp"            android:paddingBottom="8dp"            android:textColor="@color/gray"            android:textSize="14sp"            android:text="@{item.id}"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintStart_toStartOf="parent"            app:layout_constraintTop_toBottomOf="@+id/titleTV"            tools:text="Hello world" />    </androidx.constraintlayout.widget.ConstraintLayout></layout>

2) Во фрагменте нам нужно получить конфигурацию для адаптера и передать её в отображение через инстанс dataBinding, используя специальную функцию-конструктор createRecyclerConfig<I : Any, BT : ViewDataBinding>, которая создаст и вернет инстанс DataBindingRecyclerViewConfig, указав при этом id лайаута для выбранной модели, и название свойства, к которому будет прикреплена данная модель.

class RecyclerViewExampleFragment : BaseBindingFragment<RvExampleFragmentBinding, ExampleViewModel>() {      override val layoutId: Int get() = R.layout.rv_example_fragment  override val viewModel: ExampleViewModel by viewModels()        override fun initBinding(binding: RvExampleFragmentBinding) {        super.initBinding(binding)        binding.rvConfig = createRecyclerConfig<RecyclerItemUI, ItemRecyclerBinding> {            layoutId = R.layout.item_recycler        itemId = BR.item                    }    }}

Это все, что нужно сделать, чтобы связать данные из ViewModel с отображением списка в RV. Так же при создании адаптера можно назначить слушатели событий для байдинга вьюхолдера, такие как onItemClick,onItemCreate, onItemBind и другие.

А чтобы использовать вьюхолдеры с разными визуальными лайаутами, к которым привязаны свои модели отображения данных, необходимо имплементировать в них специальный интерфейс из библиотеки EasyRecyclerBinding - IBindingModelи переопределить поле layoutResId, - id лайаута, который будет отображаться для этой модели в списке.

data class RecyclerItemUI(    val id: String,    val title: String) : IBindingModel {        override val layoutResId: Int            get() = R.layout.item_recycler}data class RecyclerItemUI2(    val id: String,    val title: String) : IBindingModel {    override val layoutResId: Int        get() = R.layout.item_recycler2}

Реализация для фрагмента с разными вьюхолдерами не сильно отличается от вышеприведенной, кроме того, используется другая функция-конструктор createRecyclerMultiConfig для конфигурации адаптера, в которой уже не нужно указывать тип привязанных данных и сгенерированный класс байдинга.

class RecyclerViewExampleFragment : BaseBindingFragment<RvExampleFragmentBinding, RecyclerViewExampleViewModel>() {      override val layoutId: Int get() = R.layout.rv_example_fragment    override val viewModel: RecyclerViewExampleViewModel by viewModels()        override fun initBinding(binding: RvExampleFragmentBinding) {        super.initBinding(binding)        binding.rvConfig = createRecyclerMultiConfig {            itemId = BR.item        }    }}class RecyclerViewExampleViewModel : BasePagesViewModel() {    open val items: MutableLiveData<MutableList<IBindingModel>> = MutableLiveData()}

Таким образом, создание адаптеров для отображения данных в RV, превратилось в простую задачу состоящую из пары строчек кода, где разработчику уже не надо думать о том, как поддерживать, фактически, не нужную часть presentation слоя. И даже, если модель данных изменится, достаточно будет поменять только её отображение, и связать его с новыми данными, не заботясь о поддержке адаптеров.


Аналогичный процесс создания адаптеров для ViewPager и ViewPager2, представлен в примере на github вместе с открытым кодом, ссылку на который, я разместил в конце статьи. В настоящий момент библиотека еще дорабатывается, и хочется получить адекватный фидбек, и пожелания по дальнейшему ее развитию. Так же в неё вошли вспомогательные функции для удобного создания байдинга, в том числе в связке с ViewModel. (LayoutInflater.createBinding, Fragment.createBindingWithViewModel, etc)

Спасибо, что дочитали до конца. Приятного кодинга и хорошего настроения)

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru