Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions mvi-arch/README.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ViewState, SideEffect>
*
**/

abstract class Store<Change : StateChange, Effect : SideEffect, State : ViewState>(
initialState: State
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<VM : ViewModel> {
fun create(handle: SavedStateHandle): VM
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Class<out ViewModel>, ViewModelAssistedFactory<out ViewModel>>,
owner: SavedStateRegistryOwner,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 <StateChange, ViewState> creates new ViewState and list of SideEffects.
*/

interface StateChange
26 changes: 23 additions & 3 deletions navigation-cicerone/README.md
Original file line number Diff line number Diff line change
@@ -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/)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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(
Expand All @@ -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

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
18 changes: 15 additions & 3 deletions pagination/README.md
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Any>, updateState: UpdateState) {
submitList(data + listOfNotNull(when (updateState) {
is UpdateState.Common -> null
Expand All @@ -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<Any>) {
super.onBindViewHolder(holder, position, payloads)
if (!fullData && position >= itemCount - 10) nextPageCallback.invoke()
if (!isLoaderInTheEndInvisible && position >= itemCount - 10) nextPageCallback.invoke()
}

sealed class UpdateState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 -> {
Expand Down
Loading