Skip to content

Commit 5fde88c

Browse files
committed
feat[navigation]: add navigation module
1 parent 6eecf51 commit 5fde88c

File tree

21 files changed

+714
-0
lines changed

21 files changed

+714
-0
lines changed

.editorconfig

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# EditorConfig: http://editorconfig.org
2+
root = true
3+
4+
[*]
5+
insert_final_newline = true
6+
7+
[*.{yml, json}]
8+
indent_style = space
9+
indent_size = 2
10+
11+
[*.{kt, kts, java}]
12+
indent_size = 4
13+
max_line_length = 100

buildSrc/src/main/kotlin/BuildModules.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
object BuildModules {
55
const val app = ":app"
66
const val core = ":core"
7+
const val navigation = ":navigation"
78

89
object Features {
910
const val home = ":features:home"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.kryptkode.commonandroid.extension
2+
3+
/** Throws an [IllegalStateException] with the passed message. */
4+
fun illegal(errorMessage: String? = null): Nothing = throw IllegalStateException(errorMessage)
5+
6+
/** Throws an [UnsupportedOperationException] with the passed message. */
7+
fun unsupported(errorMessage: String? = null): Nothing =
8+
throw UnsupportedOperationException(errorMessage)

common/androidShared/src/main/java/com/kryptkode/commonandroid/livedata/event/Event.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ data class Event<out T>(private val content: T) {
2020
}
2121
}
2222

23+
/**
24+
* Consumes the content if it's not been consumed yet and run the block [block].
25+
*/
26+
fun consumeAndRun(block: (T?) -> Unit) {
27+
if (!hasBeenHandled) {
28+
block(getContentIfNotHandled())
29+
}
30+
}
31+
32+
fun consumeAndRunNonNull(block: (T) -> Unit) {
33+
consumeAndRun { if (it != null) block(it) }
34+
}
35+
2336
/**
2437
* Returns the content, even if it's already been handled.
2538
*/

common/androidShared/src/main/java/com/kryptkode/commonandroid/livedata/extension/LiveData.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ import androidx.lifecycle.Transformations
88
import com.kryptkode.commonandroid.livedata.event.Event
99
import com.kryptkode.commonandroid.livedata.event.EventObserver
1010

11+
typealias MutableLiveEvent<T> = MutableLiveData<Event<T>>
12+
13+
typealias LiveEvent<T> = LiveData<Event<T>>
14+
15+
/**
16+
* Create an event with the provided [value] and set the value of this [MutableLiveEvent]
17+
*/
18+
fun <T> MutableLiveEvent<T>.publish(value: T) {
19+
this.value = Event(value)
20+
}
21+
1122
fun <T> MutableLiveData<T>.asLiveData(): LiveData<T> = this
1223

1324
fun <T> LiveData<T>.observe(owner: LifecycleOwner, observer: (T) -> Unit) =

navigation/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build

navigation/build.gradle.kts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
plugins {
2+
androidLibrary
3+
}
4+
5+
dependencies {
6+
implementation(project(BuildModules.Commons.androidShared))
7+
8+
implementation(Libs.kotlinx_coroutines_android)
9+
implementation(Libs.kotlinx_coroutines_core)
10+
11+
implementation(Libs.timber)
12+
implementation(Libs.core_ktx)
13+
implementation(Libs.appcompat)
14+
15+
implementation(Libs.material)
16+
implementation(Libs.constraintlayout)
17+
18+
implementation(Libs.fragment_ktx)
19+
20+
implementation(Libs.lifecycle_viewmodel_ktx)
21+
implementation(Libs.lifecycle_common_java8)
22+
23+
implementation("com.github.florent37:inline-activity-result-kotlin:1.0.4")
24+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest package="com.kryptkode.navigation" />
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
package com.kryptkode.navigation
2+
3+
import android.app.Activity
4+
import android.content.Intent
5+
import android.os.Parcelable
6+
import androidx.core.app.NavUtils
7+
import androidx.fragment.app.DialogFragment
8+
import androidx.fragment.app.Fragment
9+
import androidx.fragment.app.FragmentActivity
10+
import com.kryptkode.commonandroid.extension.illegal
11+
import com.kryptkode.commonandroid.extension.unsupported
12+
import com.kryptkode.commonandroid.livedata.extension.observeEvent
13+
import com.kryptkode.navigation.direction.NavDirection
14+
import com.kryptkode.navigation.dsl.doWhen
15+
import com.kryptkode.navigation.ktx.getFragmentTag
16+
import com.kryptkode.navigation.ktx.requireDialogFragment
17+
import com.kryptkode.navigation.ktx.requireIntent
18+
import com.kryptkode.navigation.ktx.startForResultCodeWithData
19+
import com.kryptkode.navigation.model.NavigationCommand
20+
import com.kryptkode.navigation.model.NavigationResult
21+
import com.kryptkode.navigation.model.NavigationResult.Companion.RESULT_PARAM
22+
import kotlin.coroutines.Continuation
23+
import kotlin.coroutines.resume
24+
import kotlinx.coroutines.CoroutineScope
25+
import kotlinx.coroutines.coroutineScope
26+
import kotlinx.coroutines.launch
27+
28+
/**
29+
* This interface is used to centralize & encapsulate the navigation logic.
30+
* Mainly implemented by [Fragment] or [FragmentActivity]
31+
*/
32+
interface Navigable {
33+
34+
/**
35+
* Convenient method that will be used by any [Fragment] or [FragmentActivity]
36+
* to properly observe the [NavigableViewModel.router].
37+
*
38+
* Set [delegate] if you want to delegate the navigation actions to another [Navigable].
39+
*/
40+
fun <T : NavDirection> NavigableViewModel<T>.observeNavigation(delegate: Navigable? = null) {
41+
router.observeEvent(getLifecycleOwner()) {
42+
val navigable = delegate ?: this@Navigable
43+
it.handleNavigation(navigableScope, navigable)
44+
}
45+
}
46+
47+
/**
48+
* Convenient method which help the [NavigableViewModel.router] to navigate to a screen.
49+
*/
50+
fun navigateTo(direction: NavDirection) {
51+
direction.doWhen {
52+
isFragmentDialog {
53+
navigateToDialogFragment(requireDialogFragment())
54+
}
55+
isFragment {
56+
illegal(
57+
"You should manually implement " +
58+
"'navigateTo()'" +
59+
" if you want to navigate to a Fragment."
60+
)
61+
}
62+
isIntent {
63+
navigateToActivity(requireIntent())
64+
}
65+
otherDirection { unsupported() }
66+
}
67+
}
68+
69+
/**
70+
* Convenient method which help the [NavigableViewModel.router]
71+
* to navigate to a screen
72+
* and expecting a [NavigationResult] returned by it.
73+
*/
74+
suspend fun navigateToForResult(
75+
direction: NavDirection,
76+
continuation: Continuation<NavigationResult>
77+
) {
78+
coroutineScope {
79+
direction.doWhen {
80+
isFragment {
81+
illegal(
82+
"navigateToForResult()" +
83+
" is only available from [Fragment]" +
84+
" & [FragmentActivity]"
85+
)
86+
}
87+
isIntent {
88+
launch {
89+
val result = navigateToActivityForResult(requireIntent())
90+
continuation.resume(
91+
NavigationResult(code = result.first, data = result.second)
92+
)
93+
}
94+
}
95+
otherDirection { unsupported() }
96+
}
97+
}
98+
}
99+
100+
/**
101+
* Convenient method which help the [NavigableViewModel.router] to navigate
102+
* to a previous screen.
103+
* This method will be used mainly through a funnel.
104+
*
105+
* Note that this method will not working with any shared transition
106+
* (expect if you override it).
107+
*/
108+
fun navigateToPrevious(direction: NavDirection) {
109+
direction.doWhen {
110+
isFragment {
111+
illegal(
112+
"You should manually implement 'navigateToPrevious()'" +
113+
" to navigate to a previous Fragment."
114+
)
115+
}
116+
isIntent {
117+
NavUtils.navigateUpTo(requireActivity(), requireIntent())
118+
}
119+
otherDirection { unsupported() }
120+
}
121+
}
122+
123+
/**
124+
* Convenient method which help the [NavigableViewModel.router] to navigate back
125+
* (for example, when user clicks on the back button).
126+
*/
127+
fun navigateBack() {
128+
requireActivity().onBackPressed()
129+
}
130+
131+
/**
132+
* Convenient method which help the [NavigableViewModel.router] to finish the current screen.
133+
* (for example, when user clicks on any close button)
134+
*/
135+
fun navigateFinish(results: Any?) {
136+
requireActivity().run {
137+
results?.let {
138+
val parcelables = when (it) {
139+
is Parcelable -> listOf(it)
140+
is List<*> -> it.filterIsInstance<Parcelable>()
141+
else -> illegal(
142+
errorMessage = "You should pass to the finish() method " +
143+
"only 'Parcelable' or 'List<Parcelable>' variable types."
144+
)
145+
}
146+
val intent =
147+
Intent().putParcelableArrayListExtra(RESULT_PARAM, ArrayList(parcelables))
148+
setResult(Activity.RESULT_OK, intent)
149+
}
150+
finishAfterTransition()
151+
}
152+
}
153+
154+
// region Utils
155+
156+
private fun NavigationCommand.handleNavigation(
157+
scope: CoroutineScope,
158+
navigable: Navigable
159+
) {
160+
when (this) {
161+
is NavigationCommand.To -> navigable.navigateTo(direction)
162+
is NavigationCommand.ToPrevious -> navigable.navigateToPrevious(direction)
163+
is NavigationCommand.ForResult -> scope.launch {
164+
navigable.navigateToForResult(
165+
direction,
166+
continuation
167+
)
168+
}
169+
is NavigationCommand.Back -> navigable.navigateBack()
170+
is NavigationCommand.Finish -> navigable.navigateFinish(results)
171+
}
172+
}
173+
174+
private fun getLifecycleOwner() = when (this) {
175+
is FragmentActivity -> this
176+
is Fragment -> viewLifecycleOwner
177+
else -> illegal(CONTEXT_ERROR)
178+
}
179+
180+
private fun requireActivity(): Activity = when (this) {
181+
is FragmentActivity -> this
182+
is Fragment -> this.requireActivity()
183+
else -> illegal(CONTEXT_ERROR)
184+
}
185+
186+
private fun navigateToActivity(intent: Intent) = when (this) {
187+
is FragmentActivity -> startActivity(intent)
188+
is Fragment -> startActivity(intent)
189+
else -> illegal(CONTEXT_ERROR)
190+
}
191+
192+
private suspend fun navigateToActivityForResult(intent: Intent) = when (this) {
193+
is FragmentActivity -> startForResultCodeWithData(intent)
194+
is Fragment -> startForResultCodeWithData(intent)
195+
else -> illegal(
196+
"navigateToForResult() is only available" +
197+
" from [Fragment] & [FragmentActivity]"
198+
)
199+
}
200+
201+
private fun navigateToDialogFragment(dialogFragment: DialogFragment) {
202+
val fragmentManager = when (this) {
203+
is FragmentActivity -> supportFragmentManager
204+
is Fragment -> parentFragmentManager
205+
else -> illegal(CONTEXT_ERROR)
206+
}
207+
fragmentManager.beginTransaction().run {
208+
dialogFragment.show(this, dialogFragment.getFragmentTag())
209+
}
210+
}
211+
212+
companion object {
213+
private const val CONTEXT_ERROR =
214+
"This context is not handled... The Navigable interface supports " +
215+
"only [Fragment] & [FragmentActivity]" +
216+
" (or [ContextAware] objects if they only want to launch an activity)"
217+
}
218+
219+
// endregion
220+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.kryptkode.navigation
2+
3+
import androidx.lifecycle.ViewModel
4+
import androidx.lifecycle.viewModelScope
5+
import com.kryptkode.commonandroid.livedata.extension.LiveEvent
6+
import com.kryptkode.commonandroid.livedata.extension.MutableLiveEvent
7+
import com.kryptkode.commonandroid.livedata.extension.publish
8+
import com.kryptkode.navigation.direction.NavDirection
9+
import com.kryptkode.navigation.model.NavigationCommand
10+
import com.kryptkode.navigation.model.NavigationResult
11+
import kotlin.coroutines.suspendCoroutine
12+
import kotlinx.coroutines.CoroutineScope
13+
14+
/**
15+
* Convenient interface to identity a [ViewModel]
16+
* that will be able to properly handle the navigation.
17+
*/
18+
interface NavigableViewModel<T : NavDirection> {
19+
val router: LiveEvent<NavigationCommand>
20+
21+
val navigableScope: CoroutineScope
22+
get() = (this as ViewModel).viewModelScope
23+
24+
// region Navigation Extensions
25+
26+
fun MutableLiveEvent<NavigationCommand>.navigateTo(
27+
direction: NavDirection
28+
) {
29+
publish(NavigationCommand.To(direction))
30+
}
31+
32+
suspend fun MutableLiveEvent<NavigationCommand>.navigateForResult(direction: NavDirection):
33+
NavigationResult =
34+
suspendCoroutine { continuation ->
35+
publish(NavigationCommand.ForResult(direction, continuation))
36+
}
37+
38+
fun MutableLiveEvent<NavigationCommand>.navigateToPrevious(direction: T) {
39+
publish(NavigationCommand.ToPrevious(direction))
40+
}
41+
42+
fun MutableLiveEvent<NavigationCommand>.navigateBack() {
43+
publish(NavigationCommand.Back)
44+
}
45+
46+
fun MutableLiveEvent<NavigationCommand>.finish(results: Any? = null) {
47+
publish(NavigationCommand.Finish(results))
48+
}
49+
50+
// endregion
51+
}

0 commit comments

Comments
 (0)