diff --git a/mvi-arch/README.md b/mvi-arch/README.md index 6cd5bb4d..17257b5b 100644 --- a/mvi-arch/README.md +++ b/mvi-arch/README.md @@ -1,4 +1,49 @@ -mvi_arch -==== +# mvi_arch + +Модуль для реализации presentation слоя с помощью паттерна mvi. + +Модуль содержит две реализации mvi: упрощенная и полная. + +## Упрощенная реализация через MviViewModel + +### Отличия от стандартного ViewModel + +У MviViewModel есть один метод для обработки внешних событий пользователя - dispatchAction. +События должны наследоваться от интерфейса-маркера ViewAction. Также у MviViewModel есть +livedata, содржащую в себе single state экрана, который наследуется от интерфейса-маркера ViewState. +То есть у MviViewModel есть один вход и один выход. + +### MviViewModel и AssistedInject + +MviViewModel использует AssistedInject в конструктор для получения inititalState через конструктор +и механизм для сохранения данных экрана в Bundle - SavedStateHandle. +AssistedInject используется для передачи navArgs из фрагмента, который подключает ViewModel. + +### MviFragment + +Главная особенность MviFragment - связь с MviViewModel. Получить экземпляр класса MviViewModel можно через +lazy делегата - viewModel(). + +## Полная реализация через MviStoreViewModel + +### Store + +Store - реализация стора из Redux. Он держит в себе стейт машину, у которой могут быть SideEffect. Сайд эффекты обрабатываются отдельно +в одном kotlin flow. + +### MviStoreViewModel + +MviStoreViewModel наследуется от MviViewModel. Единственная ценность этого класса - он хранит в себе список Store, передает Action внутрь Store, +комбинирует стейты всех сторов в один большой стейт. + +### Почему всегда не использовать MviStoreViewModel + +Реализация полного MVI требует много кода. Для простых экранов, (т.е. для большинства экранов) рекомендуется использовать MviViewModel. + +## Дополнительные материалы + +- [Доклад про MVI от Сергея Рябова](https://youtu.be/hBkQkjWnAjg) +- [Доклад про эволюцию презентационных паттернов](https://youtu.be/J0YPKcDKumk) +- [Доклад про расширяемую архитектуру в Lyft](https://www.youtube.com/watch?v=_cFHtjIWjCc) +- [Презентация доклада со внутреннего митапа](https://docs.google.com/presentation/d/1gBg8n8xAyIytDo1-L9GvrCkrM6AFynLPYKcsQ_NPs7k/edit#slide=id.p) -TODO: rewrite dependencies diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/Store.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/Store.kt index 56eef649..5801b989 100644 --- a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/Store.kt +++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/core/Store.kt @@ -18,6 +18,15 @@ import ru.touchin.roboswag.mvi_arch.marker.SideEffect import ru.touchin.roboswag.mvi_arch.marker.StateChange import ru.touchin.roboswag.mvi_arch.marker.ViewState +/** + * Base [Store] to use in [MviStoreViewModel]. + * + * You should implement it: + * 1) define [StateChange], [SideEffect] and [ViewState] - usually sealed class with objects and data classes. + * 2) override method [reduce] - it should transform current state ViewState and StateChange to pair of + * + **/ + abstract class Store( initialState: State ) { diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelAssistedFactory.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelAssistedFactory.kt index d958ce08..9c01f7cb 100644 --- a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelAssistedFactory.kt +++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelAssistedFactory.kt @@ -3,6 +3,13 @@ package ru.touchin.roboswag.mvi_arch.di import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +/** + * For transmission SavedStateHandle of viewModel's fragment. + * + * ViewModel should have inner interface, that implements ViewModelAssistedFactory + * You should use @AssistedInject in viewModel's constructor and @Assisted in argument to assist. + */ + interface ViewModelAssistedFactory { fun create(handle: SavedStateHandle): VM } diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelFactory.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelFactory.kt index 5ae4d27e..f040ffa0 100644 --- a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelFactory.kt +++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/di/ViewModelFactory.kt @@ -6,6 +6,10 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.savedstate.SavedStateRegistryOwner +/** + * Factory that will be used by [ViewModelProvider] to instantiate viewModel in [MviFragment]. + * + */ class ViewModelFactory( private val viewModelMap: MutableMap, ViewModelAssistedFactory>, owner: SavedStateRegistryOwner, diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/SideEffect.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/SideEffect.kt index 7fea61c0..7ef2db36 100644 --- a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/SideEffect.kt +++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/SideEffect.kt @@ -1,3 +1,8 @@ package ru.touchin.roboswag.mvi_arch.marker +/** + * Class-marker to invoke asynchronous actions such as API call or database query. + * Side effects create in [Store.reduce]. + * + * */ interface SideEffect diff --git a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/StateChange.kt b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/StateChange.kt index 1d5da27a..a5021e5d 100644 --- a/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/StateChange.kt +++ b/mvi-arch/src/main/java/ru/touchin/roboswag/mvi_arch/marker/StateChange.kt @@ -1,3 +1,12 @@ package ru.touchin.roboswag.mvi_arch.marker +/** + * This interface should be implemented to create your own state change and use it with [MviFragment] and [MviStoreViewModel]. + * + * Class-marker for atomic state change. State change can come from view (in response to user's action) + * or as a SideEffect's result. + * + * StateChange affects current state: in [Store.reduce] pair creates new ViewState and list of SideEffects. + */ + interface StateChange diff --git a/navigation-cicerone/README.md b/navigation-cicerone/README.md index bca41e11..c0b6c5fc 100644 --- a/navigation-cicerone/README.md +++ b/navigation-cicerone/README.md @@ -1,4 +1,24 @@ -navigation-cicerone -==== +# navigation-cicerone -TODO: rewrite dependencies +Набор базовых классов для настройки навигации на основе библиотеки cicerone + +## Основные классы и интерфейсы + +### FlowFragment, FlowNavigation, FlowNavigationModule + +Для использования Single-Activity подхода возникают ситуации, когда несколько экранов нужно объединить одной родительской сущностью. +Объединение необходимо для хранения общего dagger компонента на flow или организации собственной навигации. +Раньше можно было использовать Activity. Но в Single-Activity должно быть только одно активити на приложение. В такой архитектуре можно использовать +parent fragment для всех экранов - FlowFragment. FlowFragment обладает своей навигацией. Для навигации используется childFragmentManager этого фрагмента. + +Чтобы использовать эту навигацию в di, в модуль добавлены dagger-модуль FlowNavigationModule, который держит основные cicerone сущности, и именная +аннотация FlowNavigation. + +### CiceroneTuner + +CiceroneTuner наследует класс LifecycleObserver для упрощенной работы с NavigatorHolder. Данный класс в onResume добавляет navigator в NavigatorHolder +и в onPause удаляет navigator из NavigatorHolder. + +## Дополнительные материалы + +- [Статья, которая объясняет Single-Activity подход](https://habr.com/ru/company/redmadrobot/blog/426617/) diff --git a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/CiceroneTuner.kt b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/CiceroneTuner.kt index ba525d38..099676ab 100644 --- a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/CiceroneTuner.kt +++ b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/CiceroneTuner.kt @@ -6,6 +6,16 @@ import androidx.lifecycle.OnLifecycleEvent import ru.terrakok.cicerone.Navigator import ru.terrakok.cicerone.NavigatorHolder +/** + * CiceroneTuner is responsible for adding Navigator to NavigatorHolder in onResume and + * removing Navigator in onPause. + * + * You should add CiceroneTuner like an Observer of SingleActivity or FlowFragment lifecycle. + * + * @param navigatorHolder - interface to connect a {@link Navigator} to the {@link Cicerone}; + * @param navigator - {@link Navigator} implementation; + */ + class CiceroneTuner( private val navigatorHolder: NavigatorHolder, private val navigator: Navigator diff --git a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowFragment.kt b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowFragment.kt index 08903be3..eae60ad5 100644 --- a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowFragment.kt +++ b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowFragment.kt @@ -14,6 +14,12 @@ import ru.touchin.mvi_arch.core_nav.R import ru.touchin.roboswag.navigation_cicerone.CiceroneTuner import javax.inject.Inject +/** + * Base parent fragment for fragments of whole feature. FlowFragment has own navigator based on childFragmentManager. + * FlowFragment is responsible for handling of back button press. + * + * You should connect FlowNavigationModule to your Dagger component and add inject method for your flow fragment. + */ abstract class FlowFragment : Fragment(R.layout.fragment_flow) { @Inject @@ -24,6 +30,18 @@ abstract class FlowFragment : Fragment(R.layout.fragment_flow) { @FlowNavigation lateinit var router: Router + private val exitRouterOnBackPressed = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + router.exit() + } + } + + open fun createNavigator(): Navigator = SupportAppNavigator( + requireActivity(), + childFragmentManager, + getFragmentContainerId() + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) injectComponent() @@ -32,14 +50,6 @@ abstract class FlowFragment : Fragment(R.layout.fragment_flow) { } } - abstract fun injectComponent() - - private val exitRouterOnBackPressed = object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - router.exit() - } - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycle.addObserver( @@ -49,14 +59,11 @@ abstract class FlowFragment : Fragment(R.layout.fragment_flow) { requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, exitRouterOnBackPressed) } - open fun createNavigator(): Navigator = SupportAppNavigator( - requireActivity(), - childFragmentManager, - getFragmentContainerId() - ) - @IdRes protected fun getFragmentContainerId(): Int = R.id.flow_parent + abstract fun injectComponent() + abstract fun getLaunchScreen(): SupportAppScreen + } diff --git a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigation.kt b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigation.kt index fa67fc74..d23e0430 100644 --- a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigation.kt +++ b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigation.kt @@ -2,5 +2,9 @@ package ru.touchin.roboswag.navigation_cicerone.flow import javax.inject.Qualifier +/** + * Qualifier to designate Router for navigation between features + */ + @Qualifier annotation class FlowNavigation diff --git a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigationModule.kt b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigationModule.kt index 25925630..59970809 100644 --- a/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigationModule.kt +++ b/navigation-cicerone/src/main/java/ru/touchin/roboswag/navigation_cicerone/flow/FlowNavigationModule.kt @@ -7,6 +7,12 @@ import ru.terrakok.cicerone.NavigatorHolder import ru.terrakok.cicerone.Router import ru.touchin.roboswag.navigation_base.scopes.FeatureScope +/** + * Module to provide Cicerone. + * + * You should add it to @Component annotation of your feature's component. + */ + @Module class FlowNavigationModule { diff --git a/pagination/README.md b/pagination/README.md index 7ff06b06..b6bc3d54 100644 --- a/pagination/README.md +++ b/pagination/README.md @@ -1,4 +1,16 @@ -pagination -==== +# pagination -TODO: rewrite dependencies +Модуль для добавления списка элементов с постраничной загрузкой. + +## Основные классы + +### Paginator +Класс наследуется от Store из модуля mvi-arch. Стейт-машина, которая отвечает за изменение состояния списка элементов. + +### PaginationView +View, которая отвечает за отображение постраничного списка. Основной метод - render, который принимает на вход Paginator.State. View состоит из +SwipeRefreshLayout и Switcher на 3 состояния: loading, error/empty, success. Success state состоит из RecyclerView, который работает с PaginationAdapter. + +## Дополнительные материалы + +- [Доклад, на котором основан модуль](https://www.youtube.com/watch?v=n9mfLWI8ktE) diff --git a/pagination/src/main/java/ru/touchin/roboswag/pagination/ErrorItem.kt b/pagination/src/main/java/ru/touchin/roboswag/pagination/ErrorItem.kt index cb5db295..b6a1fc47 100644 --- a/pagination/src/main/java/ru/touchin/roboswag/pagination/ErrorItem.kt +++ b/pagination/src/main/java/ru/touchin/roboswag/pagination/ErrorItem.kt @@ -1,3 +1,7 @@ package ru.touchin.roboswag.pagination +/** + * Element to display in the end of list if an error occurred while loading new page + */ + object ErrorItem diff --git a/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationAdapter.kt b/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationAdapter.kt index a2f456ed..6054e3fb 100644 --- a/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationAdapter.kt +++ b/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationAdapter.kt @@ -6,6 +6,14 @@ import androidx.recyclerview.widget.RecyclerView import ru.touchin.roboswag.recyclerview_adapters.adapters.AdapterDelegate import ru.touchin.roboswag.recyclerview_adapters.adapters.DelegationListAdapter +/** + * Adapter for showing [Paginator]. + * + * @param nextPageCallback - callback to load data for next page (if not all data loaded); + * @param itemIdDiff - compares whether two elements are equal; + * @param delegate - list of delegates to add to adapter of RecyclerView. + * + */ class PaginationAdapter( private val nextPageCallback: () -> Unit, private val itemIdDiff: (old: Any, new: Any) -> Boolean, @@ -19,13 +27,15 @@ class PaginationAdapter( } ) { - internal var fullData = false + // TODO: transfer to list + internal var isLoaderInTheEndInvisible = false init { addDelegate(ProgressAdapterDelegate()) delegate.forEach(this::addDelegate) } + // TODO: transfer to Paginator fun update(data: List, updateState: UpdateState) { submitList(data + listOfNotNull(when (updateState) { is UpdateState.Common -> null @@ -34,9 +44,10 @@ class PaginationAdapter( })) } + // While binding one of the last elements of the list loading of the next page starts override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List) { super.onBindViewHolder(holder, position, payloads) - if (!fullData && position >= itemCount - 10) nextPageCallback.invoke() + if (!isLoaderInTheEndInvisible && position >= itemCount - 10) nextPageCallback.invoke() } sealed class UpdateState { diff --git a/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationView.kt b/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationView.kt index d6d686a6..5fd04968 100644 --- a/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationView.kt +++ b/pagination/src/main/java/ru/touchin/roboswag/pagination/PaginationView.kt @@ -8,7 +8,12 @@ import androidx.recyclerview.widget.StaggeredGridLayoutManager import ru.touchin.extensions.setOnRippleClickListener import ru.touchin.mvi_arch.core_pagination.databinding.ViewPaginationBinding +/** + * View, responsible for displaying paginator + */ + // TODO: add an errorview with empty state and error text +// TODO: add LoadingContentView class PaginationView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null @@ -22,11 +27,13 @@ class PaginationView @JvmOverloads constructor( init { with(binding) { swipeToRefresh.setOnRefreshListener { refreshCallback() } + // TODO: delete and transfer the layoutManager setting to init elementsRecycler.layoutManager = StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL) emptyText.setOnRippleClickListener { refreshCallback() } } } + // Method for setting View: sets adapter for recyclerView and passes lambda to call while pull-to-refresh fun init(refreshCallback: () -> Unit, adapter: PaginationAdapter) { this.refreshCallback = refreshCallback this.adapter = adapter @@ -44,7 +51,7 @@ class PaginationView @JvmOverloads constructor( else -> elementsRecycler.id } ) - adapter.fullData = state === Paginator.State.Empty || state is Paginator.State.FullData<*> + adapter.isLoaderInTheEndInvisible = state === Paginator.State.Empty || state is Paginator.State.FullData<*> when (state) { is Paginator.State.EmptyError, Paginator.State.Empty, Paginator.State.EmptyProgress -> { diff --git a/pagination/src/main/java/ru/touchin/roboswag/pagination/Paginator.kt b/pagination/src/main/java/ru/touchin/roboswag/pagination/Paginator.kt index 13934448..7997d64c 100644 --- a/pagination/src/main/java/ru/touchin/roboswag/pagination/Paginator.kt +++ b/pagination/src/main/java/ru/touchin/roboswag/pagination/Paginator.kt @@ -8,6 +8,14 @@ import ru.touchin.roboswag.mvi_arch.marker.SideEffect import ru.touchin.roboswag.mvi_arch.marker.StateChange import ru.touchin.roboswag.mvi_arch.marker.ViewState +/** + * Class for state changing of list, support page-loading, implements [Store] + * + * @param errorHandleMod - error handling method (show Alert or ErrorItem); + * @param loadPage - method for loading data; + * @param pageSize - size of one page + */ + class Paginator( private val errorHandleMod: ErrorHandleMod, private val loadPage: suspend (Int) -> List, @@ -15,35 +23,67 @@ class Paginator( ) : Store(State.Empty) { sealed class Change : StateChange { + // call pull-to-refresh object Refresh : Change() + + // Full reloading data when changing external parameters: filters, sorts, etc. object Restart : Change() + + // User scrolls to the end of the list. Calls loading of new page object LoadMore : Change() + + // Clearing the list and loaded elements object Reset : Change() + + // Loading the new page was successful data class NewPageLoaded(val pageNumber: Int, val items: List) : Change() + + // Loading the new page ended with error data class PageLoadError(val error: Throwable) : Change() } sealed class Effect : SideEffect { + // Call asynchronous load of a new page data class LoadPage(val page: Int = 0) : Effect() } sealed class State : ViewState { + // Empty screen object Empty : State() + + // Loader in the middle of screen object EmptyProgress : State() + + // Error while loading first page data class EmptyError(val error: Throwable) : State() + + // Loaded list of elements data class Data(val pageCount: Int = 0, val data: List, val error: Throwable? = null) : State() + + // Show loader on pull-to-refresh data class Refresh(val pageCount: Int, val data: List) : State() + + // Loading new page data class NewPageProgress(val pageCount: Int, val data: List) : State() + + // The whole list is loaded. Nothing to load more. data class FullData(val pageCount: Int, val data: List) : State() } sealed class Error { - object NewPageFailed : Error() - object RefreshFailed : Error() + + object NewPageLoadingFailed : Error() + + object RefreshPageFailed : Error() + } + // How to react to an error sealed class ErrorHandleMod { + // Show alert for error data class Alert(val showError: (Error) -> Unit) : ErrorHandleMod() + + // Show in the end of list an element of list with error object ErrorItem : ErrorHandleMod() } @@ -105,7 +145,7 @@ class Paginator( is State.Refresh<*> -> { when (errorHandleMod) { is ErrorHandleMod.Alert -> { - errorHandleMod.showError(Error.RefreshFailed) + errorHandleMod.showError(Error.RefreshPageFailed) State.Data(currentState.pageCount, currentState.data) } is ErrorHandleMod.ErrorItem -> { @@ -120,7 +160,7 @@ class Paginator( is State.NewPageProgress<*> -> { when (errorHandleMod) { is ErrorHandleMod.Alert -> { - errorHandleMod.showError(Error.NewPageFailed) + errorHandleMod.showError(Error.NewPageLoadingFailed) State.Data(currentState.pageCount, currentState.data) } is ErrorHandleMod.ErrorItem -> { diff --git a/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressAdapterDelegate.kt b/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressAdapterDelegate.kt index f9b148c3..37eb6a8d 100644 --- a/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressAdapterDelegate.kt +++ b/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressAdapterDelegate.kt @@ -2,9 +2,13 @@ package ru.touchin.roboswag.pagination import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import ru.touchin.roboswag.recyclerview_adapters.adapters.ItemAdapterDelegate import ru.touchin.mvi_arch.core_pagination.R import ru.touchin.roboswag.components.utils.UiUtils +import ru.touchin.roboswag.recyclerview_adapters.adapters.ItemAdapterDelegate + +/** + * Delegate for displaying loader in the end of list + */ class ProgressAdapterDelegate : ItemAdapterDelegate() { diff --git a/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressItem.kt b/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressItem.kt index ffcc5fbf..0bdcc856 100644 --- a/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressItem.kt +++ b/pagination/src/main/java/ru/touchin/roboswag/pagination/ProgressItem.kt @@ -1,3 +1,7 @@ package ru.touchin.roboswag.pagination +/** + * Element to display in the end of list while loading new page + */ + object ProgressItem