diff --git a/README.md b/README.md index 48b8727..f3c4515 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,35 @@ # Eiffel [![Build Status](https://www.bitrise.io/app/d982833489004cbc/status.svg?token=66rf2t84v8SFdippsAWM8g&branch=master)](https://www.bitrise.io/app/d982833489004cbc) -[![](https://jitpack.io/v/etiennelenhart/eiffel.svg)](https://jitpack.io/#etiennelenhart/eiffel) +[![JitPack](https://jitpack.io/v/etiennelenhart/eiffel.svg)](https://jitpack.io/#etiennelenhart/eiffel) -Light-weight Kotlin Android architecture library for handling immutable view states with [Architecture Components](https://developer.android.com/topic/libraries/architecture/index.html). +![Architecture Diagram](./logo_full.svg) + +A light-weight *Kotlin* Android architecture library for handling immutable view states with [Architecture Components](https://developer.android.com/topic/libraries/architecture/index.html). + +Eiffel provides an extended `ViewModel` class with immutable state handling in conjuction with Delegated Properties for easy referencing in `Activities` and `Fragments`. + +For users of Android's [Data Binding](https://developer.android.com/topic/libraries/data-binding/index.html) framework this library adds a specialized `BindingState` to adapt an immutable state for binding and some convenience Delegated Properties for common Data Binding operations. + +As a bonus Eiffel offers wrapper classes to represent the status of business logic commands and [`LiveData`](https://developer.android.com/topic/libraries/architecture/livedata.html) values. + +## Contents +* [Installation](#installation) +* [Architecture](#architecture) +* [Immutable state](#immutable-state) +* [Basic usage](#basic-usage) + * [ViewState](#viewstate) + * [ViewModel](#viewmodel) + * [Observing](#observing) +* [Advanced usage](#advanced-usage) + * [ViewEvent](#viewevent) + * [Dependency injection](#dependency-injection) + * [Delegated Properties in Fragments](#delegated-properties-in-fragments) +* [Data Binding](#data-binding) + * [BindingState](#bindingstate) + * [Delegated Properties for Binding](#delegated-properties-for-binding) +* [Status](#status) + * [Commands](#commands) + * [LiveData](#livedata) ## Installation build.gradle *(Project)* @@ -18,6 +45,389 @@ build.gradle *(Module)* dependencies { ... implementation "android.arch.lifecycle:extensions:$architecture_version" - implementation 'com.github.etiennelenhart:eiffel:1.0.0' + implementation 'com.github.etiennelenhart:eiffel:1.2.2' +} +``` +## Architecture +Eiffel's architecture recommendation is based on Google's [Guide to App Architecture](https://developer.android.com/topic/libraries/architecture/guide.html) and therefore encourages an MVVM style. An exemplified app architecture that Eiffel facilitates is shown in the following diagram. + +![Architecture Diagram](./architecture_diagram.svg) + +## Immutable state +Eiffel encourages the use of an immutable view state, meaning that the `ViewModel` always emits a new fresh state to the observing `Activity` or `Fragment`. The view may then process and display the current state. This minimizes inconsistent UI elements and threading problems. + +## Basic usage +### ViewState +Creating a view state is as easy as implementing the `ViewState` interface: +```kotlin +data class CatViewState(val name: String = "") : ViewState +``` +Just make sure to use Kotlin's [Data Classes](https://kotlinlang.org/docs/reference/data-classes.html) and provide default values for parameters when possible. This facilitates creation of the initial state in the `ViewModel` and allows you to update the state while keeping it immutable. + +### ViewModel +`StateViewModel` inherits from Architecture Components' `ViewModel` and can therefore be used in the same way. Just use it as a base class for your `ViewModel` and provide the type of the associated `ViewState`. It is then required to override the `state` property, which exposes the `ViewState` to possible observers as a `LiveData`. +```kotlin +class CatViewModel : StateViewModel() { + override val state = MutableLiveData() + ... +} +``` +Then initialize the `ViewState` when the `ViewModel` is constructed. Just call `initState()` with an instance of the associated `ViewState`. `StateViewModel` provides a `stateInitialized` property to check whether a state has already been initialized. The current `ViewState` is then easily accessible from inside the `ViewModel` in its `currentState` property. +```kotlin +class CatViewModel : StateViewModel() { + override val state = MutableLiveData() + + init { + initState(CatViewState()) + stateInitialized // true + } + ... +} +``` +When something changes and the `ViewState` needs to be refreshed, just call `updateState`. It expects a lambda expression that receives the current state and should return the new updated state. Using a Kotlin Data Class for your states gives you the benefit of the `copy` function. This allows you to update the state while still keeping things immutable: +```kotlin +updateState { it.copy(name = "Whiskers") } +``` +The ViewModel's state `LiveData` will then notify active observers with the new view state. + +#### Delegated Properties +For easier access to ViewModels from an `Activity` Eiffel provides convenience [Delegated Properties](https://kotlinlang.org/docs/reference/delegated-properties.html). So instead of manually storing the `ViewModel` inside a lazy property and supplying a Java `Class`, use the `providedViewModel` delegate: +```kotlin +class CatActivity : AppCompatActivity() { + private val conventionalViewModel by lazy { + ViewModelProviders.of(this).get(CatViewModel::class.java) + } + private val catViewModel by providedViewModel() + ... +} +``` + +### Observing +Observing the ViewModel's `ViewState` from an `Activity` works just like observing any other `LiveData`. Simply call `observe` on the provided `state` property in `onCreate` and perform any view updates in the `Observer` lambda expression: +```kotlin +class CatActivity : AppCompatActivity() { + private val catViewModel by providedViewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.state.observe(this, Observer { nameTextView.text = it!!.name }) + } + ... +} +``` + +## Advanced usage +### ViewEvent +Sometimes the `ViewState` may contain information that should only be shown once, e.g. in an error dialog or may need to trigger one-off events like a screen change. Since the `ViewState` will be updated quite frequently and `LiveData` also emits its last value when an observer becomes active again, these events would be triggered multiple times. + +One solution would be to call a `ViewModel` function from the `Activity` that resets the triggering information in the `ViewState` to a default value and ignoring this value in the view's `Observer` logic. While this will work for smaller projects, Eiffel provides a handy `ViewEvent`. + +It should be used as a `ViewState` property which may be set to the current event. For default values, it provides a `ViewEvent.None` object to indicate that there is no event to be handled. When creating ViewEvents consider using Kotlin's [Sealed Classes](https://kotlinlang.org/docs/reference/sealed-classes.html) to constrain possible events and allow easy processing in [When Expressions](https://kotlinlang.org/docs/reference/control-flow.html#when-expression): +```kotlin +sealed class CatViewEvent : ViewEvent() { + class Meow : CatViewEvent() + class Sleep : CatViewEvent() +} +``` +The `ViewEvent` can then be added to a `ViewState` as a property: +```kotlin +data class CatViewState(val name: String = "", val event: ViewEvent = ViewEvent.None) : ViewState +``` +Now, when the `ViewModel` needs to trigger a specific event, just set the state's property to the corresponding `ViewEvent` inside `updateState`: +```kotlin +updateState { it.copy(event = CatViewEvent.Meow()) } +``` +The only violation of an immutable state that Eiffel permits is to mark a `ViewEvent` as "handled". Since these are one-off events, the possibility of inconsistent UI elements is low and the benefit of keeping the ViewModel's public functions lean prevails. + +To process and handle an event from an `Activity` you can use a when expression in the `Observer` lambda expression: +```kotlin +viewModel.state.observe(this, Observer { + ... + if (!it.event.handled) { + when (it.event) { + is CatViewEvent.Meow -> { + it.event.handled = true + // show Meow! dialog + } + is CatViewEvent.Sleep -> { + it.event.handled = true + // finish Activity + } + } + } +}) +``` + +### Dependency injection +Dependency injection (DI) is a pattern to decouple objects from their dependencies. It basically means solely working with interfaces and passing every type a specific class depends on in its constructor or functions. This effectively bans the use of "new" or static functions to instantiate these dependencies from inside the class and allows easy swapping with Mocks or Fake classes in Unit Tests. If you have never heard of Dependency injection definitely consider reading up on it. DI should be a fundamental part of a robust and modern app architecture. + +There are many ways to implement Dependency injection in your project. Two that Eiffel recommends are using Dagger 2 as a DI framework or Architecture Components' [`ViewModelProvider.Factory`](https://developer.android.com/reference/android/arch/lifecycle/ViewModelProvider.Factory.html). Since Dagger involves a relatively high learning curve only the provider factories will be shown here. If you're not familiar with Dagger but consider using it you may start at the official [Dagger User's Guide](https://google.github.io/dagger/users-guide). + +#### Provider factory +At the moment of writing this readme the documentation on `ViewModelProvider` factories is sparse. Using them is actually pretty straightforward though. You basically just need to create a subclass of the `ViewModelProvider.NewInstanceFactory` class. Instances of this class will be used by the framework when retrieving a `ViewModel` from a `ViewModelProvider`. + +So start by creating a factory for a `ViewModel` that requires additional dependencies. Let's say the `CatViewModel` shown in the [ViewModel section](#viewmodel) now needs some cat food: +```kotlin +class CatViewModel(private val food: CatFood) : StateViewModel() { ... } + +class CatFactory : ViewModelProvider.NewInstanceFactory() { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return CatViewModel(DryFood()) as T + } +} +``` +Now when getting an instance of the `ViewModel` in an `Activity` you can supply the corresponding factory to the `ViewModelProvider`. Since Activities are instantiated by the system and therefore don't support direct injection, the easiest way to supply an `Activity` with a factory is to provide it as a property in a custom `Application` class the `Activity` has access to. To prevent excessive memory use you can implement a [custom getter](https://kotlinlang.org/docs/reference/properties.html#getters-and-setters) that creates a factory instance on demand: +```kotlin +class FriendlyMittens : Application() { + val catFactory: ViewModelProvider.NewInstanceFactory + get() = CatFactory() + ... +} +``` +If you have to keep any dependencies in memory independent of a `ViewModel` lifespan you may leverage Kotlin's [Lazy properties](https://kotlinlang.org/docs/reference/delegated-properties.html#lazy) inside factories or for the factory itself. Similarly, global dependencies required by multiple factories may of course be stored in a property of the custom `Application` and injected in the facrories' constructors. + +Eiffel provides overloads for its [Delegated Properties](#delegated-properties) to make getting a `ViewModel` with a custom factory a bit more concise: +```kotlin +class CatActivity : AppCompatActivity() { + private val catViewModel by providedViewModel { + (application as FriendlyMittens).catFactory + } + ... +} +``` +The factory is provided in a lambda expression since the `application` property may be `null` before the Activity's `onCreate` method has been called. + +### Delegated Properties in Fragments +Analog to the `ViewModel` [Delegated Properties](#delegated-properties) for an `Activity` Eiffel provides delegates for use in a `Fragment`. Getting a Fragment's corresponding `ViewModel` works just like in an `Activity`: +```kotlin +class KittenFragment : Fragment() { + private val kittenViewModel by providedViewModel() + private val boredKittenViewModel by providedViewModel { + (application as FriendlyMittens).boredKittenFactory + } + ... +} +``` +A propably more common case though is to share an Activity's `ViewModel` across multiple Fragments (see the [ViewModel documentation](https://developer.android.com/topic/libraries/architecture/viewmodel.html#sharing) for more info). Eiffel contains a special Delegated Property to facilitate the use of these shared ViewModels even with custom factories: +```kotlin +class KittenFragment : Fragment() { + private val catViewModel by sharedViewModel { + (application as FriendlyMittens).catFactory + } + ... +} +``` +Internally the delegate supplies the `ViewModelProvider` with the Fragment's associated `Activity` by using its `activity` property. This keeps the `ViewModel` scoped to this `Activity` and all Fragments receive the same instance. + +## Data Binding +If you want to use Android's [Data Binding](https://developer.android.com/topic/libraries/data-binding/index.html) framework in your project, Eiffel's got you covered, too. There would be a couple of issues when using an immutable `ViewState` directly in data bindings. Setting individual properties as variables for a binding would essentially break the notification of any changes once the state has been updated with a new instance. While using the whole state as a variable may work, it will definitely break when you're trying to use two-way binding. + +### BindingState +To solve these issues Eiffel contains a simple `BindingState` interface that you can base your binding specific states on. It emposes a single `refresh` function that receives the corresponding `ViewState`. An added benefit when using a dedicated `BindingState` is that you can keep your ViewStates pretty generic and agnostic to view details and resources. + +Let's say you're making a view for an angry cat that meows a lot. The `ViewState` can be designed without any knowledge of the view's actual layout. It just needs to provide an indicator whether the cat is currently meowing: +```kotlin +data class AngryCatViewState(val meowing: Boolean = false) : ViewState +``` +The `BindingState` can then be constructed as complex or simple as needed by the layout. You may extend the state from `BaseObservable` to notify the binding about changes or use ObservableFields (See the [Data Binding documentation](https://developer.android.com/topic/libraries/data-binding/index.html#data_objects) on Data Objects for more info): +```kotlin +class AngryCatBindingState : BindingState { + val soundResId = ObservableInt(0) + val catResId = ObservableInt(R.drawable.cat) + + override fun refresh(state: AngryCatViewState) { + soundResId.set(if (state.meowing) R.string.meow else 0) + catResId.set(if (state.meowing) R.drawable.angry_cat else R.drawable.cat) + } +} +``` +In the `Activity` or `Fragment` the state can then be easily refreshed from the ViewModel's `state` property: +```kotlin +class AngryCatActivity : AppCompatActivity() { + ... + private val state = AngryCatBindingState() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + viewModel.state.observe(this, Observer { state.refresh(it!!) }) + binding.state = state + } +} +``` +To use the `BindingState` in the layout XML just set it as a variable and bind the views to the respective properties: +```xml + + + + + + + ... + + + + + + ... + + +``` + +### Delegated Properties for Binding +To make working with Data Binding just a little bit more convenient, Eiffel provides some Delegated Properties. The `notifyBinding` delegate allows you to easily notify a changed value to a binding when extending [`BaseObervable`](https://developer.android.com/topic/libraries/data-binding/index.html#observable_objects): +```kotlin +class AngryCatBindingState : BaseObservable(), BindingState { + @get:Bindable + var soundResId by notifyBinding(0, BR.soundResId) + + @get:Bindable + var catResId by notifyBinding(R.drawable.cat, BR.catResId) + + override fun refresh(state: AngryCatViewState) { + soundResId = if (state.meowing) R.string.meow else 0 + catResId = if (state.meowing) R.drawable.angry_cat else R.drawable.cat + } +} +``` +The `contentView` delegate lazily provides a binding in an `Activity` and simultaneously sets it as the content view: +```kotlin +class AngryCatActivity : AppCompatActivity() { + private val binding by contentViewBinding(R.layout.activity_angry_cat) + ... +} +``` + +## Status +### Commands +For your business logic Eiffel encourages a variation of [Clean Architecture's](https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.html) Use Cases. In its essence these can be seen as interactions that receive a request and return a result. A simple interface declaration could look like this: +```kotlin +// Example, not contained in Eiffel +interface UseCase { + fun execute(request: Request): Result +} +``` +A class with a single function that receives some parameters and returns something pretty much resembles a basic lambda expression in Kotlin. Therefore Eiffel doesn't come with any predefined interfaces or classes for Use Cases. The documentation may refer to them as "commands" but they may be implemented simply by using lambda expressions. + +You could, of course, create a generic interface with type parameters for the request and result part, but why bother? The expressions's parameters represent the "request" part. Since there is no need for a specialized interface you can supply a single request instance or every required input separately, whatever makes more sense to you. + +Where Eiffel tries to simplify things a bit is in the "result" part. Most of the time a command may return a single entity of data, like a primitive value or an instance of a [Data Class](https://kotlinlang.org/docs/reference/data-classes.html). The crucial point with business logic commands though is that they may be asynchronous and most importantly can just fail to complete successfully. + +Eiffel provides some wrapper classes to associate a status to a command's result. `Result` for simple commands and `ResultWithData` for commands that return some data. Both wrappers are implemented as [Sealed Classes](https://kotlinlang.org/docs/reference/sealed-classes.html) to allow easy processing in [When Expressions](https://kotlinlang.org/docs/reference/control-flow.html#when-expression). They contain `Success`, `Pending` and `Error` variants. + +Since you'll propably want to inject these commands into a `ViewModel` it's recommended to use Kotlin's [Type aliases](https://kotlinlang.org/docs/reference/type-aliases.html) for lambda expressions that represent commands. So instead of specifying the complete type of the expression, which may get clunky especially with multiple parameters, just supply the Type alias. + +Let's say you want to persist the number of times an angry cat has meowed already. First create Type aliases that specify the required inputs and the type of results: +```kotlin +typealias MeowCount = () -> ResultWithData +typealias PersistMeowCount = (count: Int) -> Result +``` +Then add the commands as dependencies in the respective `ViewModel`: +```kotlin +class CatViewModel( + private val food: CatFood, + private val meows: MeowCount, + private val persistMeows: PersistMeowCount +) : StateViewModel() { ... } +``` +Now you'll need to supply implementations of the commands when creating an instance of the `ViewModel` in the corresponding `ViewModelProvider.Factory` (Refer to the [Dependency injection](#dependency-injection) section for more info): +```kotlin +class CatFactory : ViewModelProvider.NewInstanceFactory() { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return CatViewModel( + food = DryFood(), + meows = { + val count = // get count from SharedPreferences + if (/* succeeded */) + ResultWithData.Success(count) + else + ResultWithData.Error(-1, /* optional ErrorType */) + }, + persistMeows = { count: Int -> + // persist count in SharedPreferences + if (/* succeeded */) + Result.Success + else + Result.Error(/* optional ErrorType */) + } + ) as T + } +} +``` +In the `ViewModel` the commands can then be used like any other lambda expression: +```kotlin +class CatViewModel( + private val food: CatFood, + private val meows: MeowCount, + private val persistMeows: PersistMeowCount +) : StateViewModel() { + ... + + init { + val meowCount = meows().let { + when (it.status) { + Status.Error -> { /* Process error */ } + else -> {} + } + it.data + } + ... + } + + fun persistCount() { + when(persistMeows(/* count */)) { + is Result.Error -> { /* Process error */ } + else -> {} + } + } +} +``` +If you want to supply an `ErrorType` just create an implementation of the `ErrorType` as a Sealed Class and use it for `Result.Error` or `ResultWithData.Error`: +```kotlin +sealed class SharedPreferencesError : ErrorType { + object : ValueNotFound : SharedPreferencesError() + ... +} + +... + if (/* succeeded */) Result.Success else Result.Error(SharedPreferencesError.ValueNotFound) +... +``` + +#### Result types +Since Eiffel doesn't constrain commands you are completely free in specifying result types. So there are virtually endless possibilities for your commands. You can even leverage the power of Kotlin's [Coroutines](https://kotlinlang.org/docs/reference/coroutines.html) to create easy to use asynchronous commands. Check below for some possible combinations: +```kotlin +typealias FireAndForget = () -> Unit +typealias ReturnWithStatus = () -> Result +typealias ReturnWithData = () -> ResultWithData + +typealias Async = () -> Job +typealias ReturnWithStatusAsync = () -> Deferred +typealias ReturnWithDataAsync = () -> Deferred> + +typealias ContinuousStatusUpdates = () -> ProducerJob +typealias ContinuousStatusUpdatesWithData = () -> ProducerJob> +``` + +### LiveData +Continously updated information that observers may subscribe to like Architecture Components' [`LiveData`](https://developer.android.com/topic/libraries/architecture/livedata.html) can also benefit from an associated status. It even gets briefly mentioned in Android Developers' [Guide to App Architecture]([`LiveData`](https://developer.android.com/topic/libraries/architecture/guide.html#addendum)). Eiffel contains a simple `Resource` Sealed Class that essentially works just like `ResultWithData` does for commands. Just wrap the LiveData's data type with a `Resource` and internally update the value with one of its variants: +```kotlin +class CatMilkLiveData : LiveData>() { + ... + + fun statusChanged() { + ... + value = Resource.Success(MilkStatus.Full) + ... + value = Resource.Error(MilkStatus.Empty, MilkError.Spilled) + } } ``` diff --git a/architecture_diagram.svg b/architecture_diagram.svg new file mode 100644 index 0000000..f3426fc --- /dev/null +++ b/architecture_diagram.svg @@ -0,0 +1,2 @@ + +
Dependency Injection
(ViewModel Factory / Dagger)
[Not supported by viewer]
XML
<font style="font-size: 48px">XML</font>
view
[Not supported by viewer]
state
[Not supported by viewer]
Data Binding
[Not supported by viewer]
Activity / Fragment
<font style="font-size: 24px">Activity / Fragment</font>
View
[Not supported by viewer]
BindingState
[Not supported by viewer]
StateViewModel
<span style="font-size: 24px">StateViewModel</span>
ViewState
(LiveData)
[Not supported by viewer]
methods
[Not supported by viewer]
ViewModel
[Not supported by viewer]
DAO
[Not supported by viewer]
LiveData
[Not supported by viewer]
Database
[Not supported by viewer]
Server
[Not supported by viewer]
[Not supported by viewer]
Firebase
[Not supported by viewer]
Location
[Not supported by viewer]
[Not supported by viewer]
Model
[Not supported by viewer]
λ
(command / query)
[Not supported by viewer]
Use case
[Not supported by viewer]
Preferences
[Not supported by viewer]
[Not supported by viewer]
observe
[Not supported by viewer]
call
[Not supported by viewer]
\ No newline at end of file diff --git a/build.gradle b/build.gradle index d24c90d..2c3f8b6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { apply from: 'version.gradle' - ext.kotlin_version = '1.2.10' + ext.kotlin_version = '1.2.21' repositories { google() jcenter() diff --git a/eiffel/build.gradle b/eiffel/build.gradle index 3966740..03714c3 100644 --- a/eiffel/build.gradle +++ b/eiffel/build.gradle @@ -9,7 +9,7 @@ android { defaultConfig { minSdkVersion 19 targetSdkVersion 27 - buildToolsVersion "27.0.2" + buildToolsVersion "27.0.3" versionCode versionCodeArgument() versionName versionNameArgument() setProperty("archivesBaseName", "eiffel-${android.defaultConfig.versionName}") @@ -33,7 +33,7 @@ dependencies { testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version" androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1' - implementation "android.arch.lifecycle:extensions:1.0.0" + implementation "android.arch.lifecycle:extensions:1.1.0" } task sources(type: Jar) { diff --git a/eiffel/src/main/java/com/etiennelenhart/eiffel/ErrorType.kt b/eiffel/src/main/java/com/etiennelenhart/eiffel/ErrorType.kt index 49ecf17..155acd2 100644 --- a/eiffel/src/main/java/com/etiennelenhart/eiffel/ErrorType.kt +++ b/eiffel/src/main/java/com/etiennelenhart/eiffel/ErrorType.kt @@ -1,7 +1,7 @@ package com.etiennelenhart.eiffel /** - * Type of error that occurred while executing a command or notifying a LiveData value. + * Type of error that occurred while executing a command or before notifying a LiveData value. */ interface ErrorType { diff --git a/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/BindingState.kt b/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/BindingState.kt index cb63ee6..9174c30 100644 --- a/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/BindingState.kt +++ b/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/BindingState.kt @@ -5,7 +5,7 @@ import com.etiennelenhart.eiffel.state.ViewState /** * State that adapts a [ViewState] to a bindable state for data binding. * - * Since [ViewState]s should expose view agnostic properties, any adaptions + * Since [ViewState]s should expose layout agnostic properties, any adaptions * needed to properly display the current state can be made inside this state's * [refresh] method. * @@ -14,13 +14,15 @@ import com.etiennelenhart.eiffel.state.ViewState interface BindingState { /** - * Perform any adaptions needed to properly display the current state here. + * Make any adaptions needed to properly display the current state here. * For example, when using observable fields: * * ``` - * class SampleBindingState(val loading: ObservableBoolean = ObservableBoolean(false)) : BindingState { - * fun refresh(state: SampleViewState) { - * loading.set(state.status == Status.PENDING) + * class SampleBindingState : BindingState { + * val progressBarVisible = ObservableBoolean(false) + * + * override fun refresh(state: SampleViewState) { + * progressBarVisible.set(state.status == Status.PENDING) * } * } * ``` diff --git a/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/delegate/ContentViewBinding.kt b/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/delegate/ContentViewBinding.kt index 024a483..5c5e54f 100644 --- a/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/delegate/ContentViewBinding.kt +++ b/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/delegate/ContentViewBinding.kt @@ -27,3 +27,16 @@ class ContentViewBinding(@LayoutRes private val layoutI return value!! } } + +/** + * Convenience extension function for the [ContentViewBinding] delegate. + * + * May be used in an [Activity] like this: + * ``` + * val binding by contentViewBinding(R.layout.activity_sample) + * ``` + * + * @param[T] Type of the provided binding. + * @param[layoutId] ID of the layout to bind and set as content view. + */ +fun Activity.contentViewBinding(@LayoutRes layoutId: Int) = ContentViewBinding(layoutId) diff --git a/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/delegate/NotifyBinding.kt b/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/delegate/NotifyBinding.kt index cf5580f..2c05524 100644 --- a/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/delegate/NotifyBinding.kt +++ b/eiffel/src/main/java/com/etiennelenhart/eiffel/binding/delegate/NotifyBinding.kt @@ -27,3 +27,19 @@ class NotifyBinding(private var value: T, private val fieldId: Int) : R thisRef.notifyPropertyChanged(fieldId) } } + +/** + * Convenience extension function for the [NotifyBinding] delegate. + * + * The delegating property should be annotated with `@get:Bindable` to generate a field in BR. + * May be used in a [BaseObservable] like this: + * ``` + * @get:Bindable + * var sampleValue by notifyBinding("", BR.sampleValue) + * ``` + * + * @param[T] Type of the notifying property. + * @param[value] Initial value. + * @param[fieldId] The id of the generated BR field. + */ +fun BaseObservable.notifyBinding(value: T, fieldId: Int) = NotifyBinding(value, fieldId) diff --git a/eiffel/src/main/java/com/etiennelenhart/eiffel/livedata/Resource.kt b/eiffel/src/main/java/com/etiennelenhart/eiffel/livedata/Resource.kt index 5da0dd5..aa1b12e 100644 --- a/eiffel/src/main/java/com/etiennelenhart/eiffel/livedata/Resource.kt +++ b/eiffel/src/main/java/com/etiennelenhart/eiffel/livedata/Resource.kt @@ -7,26 +7,26 @@ import com.etiennelenhart.eiffel.Status * Wrapper class to associate a status to a LiveData value. * * @param[T] Type of the LiveData's value. - * @property[status] LiveData value's current status. + * @property[status] Current status of the LiveData value. * @property[value] LiveData's actual value. */ sealed class Resource(val status: Status, val value: T) { /** - * Resource variant signaling a successful LiveData operation. + * Resource variant signaling a successful LiveData value. * * @param[value] Actual value. */ class Success(value: T) : Resource(Status.SUCCESS, value) /** - * Resource variant signaling a pending LiveData operation. + * Resource variant signaling a pending LiveData value. * * @param[value] Actual value. */ class Pending(value: T) : Resource(Status.PENDING, value) /** - * Resource variant signaling a failed LiveData operation. + * Resource variant signaling a failed LiveData value. * * @param[value] Actual value. * @param[type] Optional [ErrorType]. Defaults to [ErrorType.Unspecified]. diff --git a/eiffel/src/main/java/com/etiennelenhart/eiffel/state/ViewEvent.kt b/eiffel/src/main/java/com/etiennelenhart/eiffel/state/ViewEvent.kt index 894e78c..0cce749 100644 --- a/eiffel/src/main/java/com/etiennelenhart/eiffel/state/ViewEvent.kt +++ b/eiffel/src/main/java/com/etiennelenhart/eiffel/state/ViewEvent.kt @@ -1,7 +1,7 @@ package com.etiennelenhart.eiffel.state /** - * Base class for view model actions that require an Activity. + * Base class for one-off view model actions that require an Activity. * * Can be used as a property in [ViewState]s to signal an action to the observer: * @@ -13,7 +13,7 @@ package com.etiennelenhart.eiffel.state * * ``` * sealed class SampleViewEvent : ViewEvent() { - * class ActivityAction : SampleViewEvent() + * class ShowSample : SampleViewEvent() * } * ``` * @@ -23,7 +23,7 @@ package com.etiennelenhart.eiffel.state * viewModel.state.observe(this, Observer { * if (!it.event.handled) { * when (it.event) { - * is SampleViewEvent.ActivityAction -> { + * is SampleViewEvent.ShowSample -> { * it.event.handled = true * ... * } @@ -33,11 +33,11 @@ package com.etiennelenhart.eiffel.state * ``` * * @param[handled] 'true' when the event is already handled. Defaults to false. - * @property[handled] 'false' when the event has yet to be handled. Set to 'true' when event is handled. + * @property[handled] 'false' when the event has yet to be handled. Set to 'true' when event has been handled. */ abstract class ViewEvent(var handled: Boolean = false) { /** - * Convenience [ViewEvent] to set as an initial event that requires no action. + * Convenience [ViewEvent] to set as an initial event that requires no handling. * * This event's 'handled' property is initialized to 'true'. */ diff --git a/eiffel/src/main/java/com/etiennelenhart/eiffel/state/ViewState.kt b/eiffel/src/main/java/com/etiennelenhart/eiffel/state/ViewState.kt index 2d1ec22..6ea117c 100644 --- a/eiffel/src/main/java/com/etiennelenhart/eiffel/state/ViewState.kt +++ b/eiffel/src/main/java/com/etiennelenhart/eiffel/state/ViewState.kt @@ -1,10 +1,12 @@ package com.etiennelenhart.eiffel.state +import com.etiennelenhart.eiffel.viewmodel.StateViewModel + /** * Marker interface for data classes that represent a view state. * * Only use `val`s and immutable data structures for properties and try * to avoid view specifics like resource IDs or data binding logic. - * Implementing classes can be used as state in [StateViewModel]. + * Implementing classes can be used as a state in [StateViewModel]. */ interface ViewState diff --git a/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/StateViewModel.kt b/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/StateViewModel.kt index 090998b..ada06c3 100644 --- a/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/StateViewModel.kt +++ b/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/StateViewModel.kt @@ -36,7 +36,7 @@ abstract class StateViewModel : ViewModel() { } /** - * Updates the current state by applying the 'update' lambda. + * Updates the current state by applying the supplied lambda expression. * * May be used like this: * @@ -44,10 +44,10 @@ abstract class StateViewModel : ViewModel() { * updateState { it.copy(sample = true) } * ``` * - * @param[update] Lambda to update the current view state. + * @param[newState] Lambda expression that should return the new updated state. * @throws[KotlinNullPointerException] when the state's initial value has not been set. */ - protected inline fun updateState(update: (currentState: T) -> T) { - (state as MutableLiveData).value = update(currentState) + protected inline fun updateState(newState: (currentState: T) -> T) { + (state as MutableLiveData).value = newState(currentState) } } diff --git a/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/ProvidedFactoryViewModel.kt b/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/ProvidedFactoryViewModel.kt index 0ec547e..653ac40 100644 --- a/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/ProvidedFactoryViewModel.kt +++ b/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/ProvidedFactoryViewModel.kt @@ -18,10 +18,12 @@ import kotlin.reflect.KProperty * * @param[T] Type of the provided view model. * @param[viewModelClass] Java class of the provided view model. - * @param[factory] Block to lazily get the factory to instantiate the view model. + * @param[factory] Lambda expression to lazily get the factory to instantiate the view model. */ -class ProvidedFactoryViewModel(private val viewModelClass: Class, - private val factory: () -> ViewModelProvider.Factory) : ReadOnlyProperty { +class ProvidedFactoryViewModel( + private val viewModelClass: Class, + private val factory: () -> ViewModelProvider.Factory +) : ReadOnlyProperty { private var value: T? = null @@ -36,12 +38,11 @@ class ProvidedFactoryViewModel(private val viewModelClass: Cl * * May be used in a [FragmentActivity] like this: * ``` - * val viewModel by providedViewModel(sampleFactory) + * val viewModel by providedViewModel { sampleFactory } * ``` * * @param[T] Type of the provided view model. - * @param[factory] Block to lazily get the factory to instantiate the view model. + * @param[factory] Lambda expression to lazily get the factory to instantiate the view model. */ -inline fun FragmentActivity.providedViewModel(noinline factory: () -> ViewModelProvider.Factory): ProvidedFactoryViewModel { - return ProvidedFactoryViewModel(T::class.java, factory) -} +inline fun FragmentActivity.providedViewModel(noinline factory: () -> ViewModelProvider.Factory) = + ProvidedFactoryViewModel(T::class.java, factory) diff --git a/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/ProvidedFragmentFactoryViewModel.kt b/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/ProvidedFragmentFactoryViewModel.kt index 29ff13a..54b330f 100644 --- a/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/ProvidedFragmentFactoryViewModel.kt +++ b/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/ProvidedFragmentFactoryViewModel.kt @@ -18,10 +18,12 @@ import kotlin.reflect.KProperty * * @param[T] Type of the provided view model. * @param[viewModelClass] Java class of the provided view model. - * @param[factory] Block to lazily get the factory to instantiate the view model. + * @param[factory] Lambda expression to lazily get the factory to instantiate the view model. */ -class ProvidedFragmentFactoryViewModel(private val viewModelClass: Class, - private val factory: () -> ViewModelProvider.Factory) : ReadOnlyProperty { +class ProvidedFragmentFactoryViewModel( + private val viewModelClass: Class, + private val factory: () -> ViewModelProvider.Factory +) : ReadOnlyProperty { private var value: T? = null @@ -36,12 +38,11 @@ class ProvidedFragmentFactoryViewModel(private val viewModelC * * May be used in a [Fragment] like this: * ``` - * val viewModel by providedFactoryViewModel(sampleFactory) + * val viewModel by providedFactoryViewModel { sampleFactory } * ``` * * @param[T] Type of the provided view model. - * @param[factory] Block to lazily get the factory to instantiate the view model. + * @param[factory] Lambda expression to lazily get the factory to instantiate the view model. */ -inline fun Fragment.providedViewModel(noinline factory: () -> ViewModelProvider.Factory): ProvidedFragmentFactoryViewModel { - return ProvidedFragmentFactoryViewModel(T::class.java, factory) -} +inline fun Fragment.providedViewModel(noinline factory: () -> ViewModelProvider.Factory) = + ProvidedFragmentFactoryViewModel(T::class.java, factory) diff --git a/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/SharedFactoryViewModel.kt b/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/SharedFactoryViewModel.kt index e74b81a..5e6270a 100644 --- a/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/SharedFactoryViewModel.kt +++ b/eiffel/src/main/java/com/etiennelenhart/eiffel/viewmodel/delegate/SharedFactoryViewModel.kt @@ -18,10 +18,12 @@ import kotlin.reflect.KProperty * * @param[T] Type of the provided view model. * @param[viewModelClass] Java class of the provided view model. - * @param[factory] Block to lazily get the factory to instantiate the view model. + * @param[factory] Lambda expression to lazily get the factory to instantiate the view model. */ -class SharedFactoryViewModel(private val viewModelClass: Class, - private val factory: () -> ViewModelProvider.Factory) : ReadOnlyProperty { +class SharedFactoryViewModel( + private val viewModelClass: Class, + private val factory: () -> ViewModelProvider.Factory +) : ReadOnlyProperty { private var value: T? = null @@ -36,12 +38,10 @@ class SharedFactoryViewModel(private val viewModelClass: Clas * * May be used in a [Fragment] like this: * ``` - * val viewModel by sharedViewModel(sampleFactory) + * val viewModel by sharedViewModel { sampleFactory } * ``` * * @param[T] Type of the provided view model. - * @param[factory] Block to lazily get the factory to instantiate the view model. + * @param[factory] Lambda expression to lazily get the factory to instantiate the view model. */ -inline fun Fragment.sharedViewModel(noinline factory: () -> ViewModelProvider.Factory): SharedFactoryViewModel { - return SharedFactoryViewModel(T::class.java, factory) -} +inline fun Fragment.sharedViewModel(noinline factory: () -> ViewModelProvider.Factory) = SharedFactoryViewModel(T::class.java, factory) diff --git a/logo_full.svg b/logo_full.svg new file mode 100644 index 0000000..ec7d163 --- /dev/null +++ b/logo_full.svg @@ -0,0 +1 @@ +logo_full \ No newline at end of file