Refresh Data - Kotlin - Flows - MVVM - Jetpack Compose - android

I'm trying to implement a refresh button. I want to be able to trigger the api call again when the refresh button is clicked. Kind of confused on what the best practice is. Here is my view model and composable.
View Model:
#HiltViewModel
class CoinListViewModel #Inject constructor(
private val getAllCoinsUseCase: GetListOfCoinsUseCase
): ViewModel() {
private val _state = mutableStateOf(CoinsListState()) // not exposed because mutable
val state: State<CoinsListState> = _state // expose this to composable because immutable
init {
getData()
}
// method to call the use case - put the data in the state object - then display state in the ui
private fun getData(){
getAllCoinsUseCase().onEach { resourceResult ->
when(resourceResult){
is Resource.Loading -> {
_state.value = CoinsListState(isLoading = true)
}
is Resource.Success -> {
_state.value = CoinsListState(coins = resourceResult.data ?: emptyList())
}
is Resource.Error -> {
_state.value = CoinsListState(
error = resourceResult.message ?: "Unexpected Error"
)
}
}
}.launchIn(viewModelScope) // must launch in coroutine scope because using a flow
}
}
Refresh Button:
#Composable
fun RefreshButton(navController: NavController, viewModel: CoinListViewModel) {
// Refresh Button
IconButton(
onClick = {
// Refresh Data
},
modifier = Modifier
.semantics {
contentDescription = "Refresh Button"
testTag = "Refresh Button Test Tag"
},
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Refresh Icon"
)
}
}

Keep your getData function private and add another function you can call it onRefreshDataEvent for example, and on this function call getData. You may say why I can just call getData directly, but by this approach we are separating the refresh event from getData function because you can have another function called getCachedData and you call it instead or you can have a limit, for example you will not refresh data only one time per minute, so all of this logic will be on onRefreshDataEvent and your first getData function stay clean and do it's job.
fun onRefreshDataEvent() {
getData()
}
You can add a time check for example so the user couldn't spam the refresh button, and the refresh could be used only a single time for each minute:
private var lastRefreshTime = 0
fun onRefreshDataEvent() {
val currentTime = System.currentTimeMillis()
if (currentTime - lastRefreshTime > (1000 * 60)) {
getData()
lastRefreshTime = currentTime
}
}
So imagine that the last logic is implemented in getData function, the code will be messy.

Related

LaunchedEffect triggering even thought composition should have ended and key changed

I'm using Compose to build my Android UI.
I have a screen where I want to be able to search for stocks and show them in a LazyColumn. For triggering the API call I'm using a LaunchedEffect like this.
val stocks = remember { mutableStateListOf<Stock>() }
var searchText by remember { mutableStateOf("") }
val hasSearchEnoughChars = searchText.length >= 3
...
if(hasSearchEnoughChars) {
LaunchedEffect(key1 = searchText) {
delay(500)
searchStocksForText(searchText) {
isSearching = false
wereStocksFound = it.isNotEmpty()
stocks.clear()
stocks.addAll(it)
}
}
} else {
stocks.clear()
}
...
SearchField(
onValueChanged = {
searchText = it
}
)
...
private fun SearchField(
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier,
isError: Boolean = false
) {
var inputText by remember { mutableStateOf("") }
OutlinedTextField(
value = inputText,
onValueChange = {
inputText = it
onValueChanged(it)
},
...
)
}
This is how searchText is updated.
fun searchStocksForText(searchText: String, onDataReceived: (List<Stock>) -> Unit) {
StockApiConnection().getStocksViaSearch(
query = searchText,
onSuccess = { onDataReceived(it) },
onFailure = { onDataReceived(emptyList()) }
)
}
This is the async function which is build on top of a retrofit callback.
So far so good, but I'm experiencing a weird behavior of LaunchedEffect in an edgecase.
When having typed 4 Chars into the Textfield (represented by searchText) and erasing 2 of them with a slight delay (probably the delay(500) from LaunchedEffect) the stocks will still be fetched for the 3-char-sized searchText and therefore shown in the LazyColumn.
I also already tried using a CoroutineScope, having the if(hasSearchEnoughChars) statement inside of the LaunchedEffect and also aborting the LaunchedEffect / Scope in the else Branch but nothing seems to work. Curiously the API is not called when typing fast, except the last one after 500ms, as intended.
For my understanding LaunchedEffect should cancel the current Coroutine
when the Key changes and
when the Composable leaves the composition
which should booth be the case but the callback is still triggered.
Is there something I'm missing when handling async callbacks in LaunchedEffect or is my understanding of LaunchedEffect wrong?
searchStocksForText() is an asynchronous function with callback instead of a suspend function, so if the coroutine is cancelled after it has already been fired, it cannot be cancelled and it's callback will still be run. You need to convert it into a suspend function:
suspend fun searchStocksForText(searchText: String): List<Stock> = suspendCancellableCoroutine { cont ->
StockApiConnection().getStocksViaSearch(
query = searchText,
onSuccess = { cont.resume(it) },
onFailure = { cont.resume(emptyList()) }
)
}
Then you can call the code synchronously in your coroutine, and it will be cancellable appropriately:
if(hasSearchEnoughChars) {
LaunchedEffect(key1 = searchText) {
delay(500)
val stocks = searchStocksForText(searchText)
isSearching = false
wereStocksFound = it.isNotEmpty()
stocks.clear()
stocks.addAll(it)
}
} else {
stocks.clear()
}
However, I think using a launched effect for this is kind of convoluted. You might try doing it with a Flow and using debounce(). I didn't test this, so beware. Still a newbie to Compose myself, and I'm not sure if the cold flow needs to be stored in a remember parameter before you call collectAsStateWithLifecycle() on it.
val searchText = remember { MutableStateFlow("") }
val stocks: State<List<Stock>> = searchText
.debounce(500)
.onEach { isSearching = true }
.map { if (it.length >= 3) searchStocksForText(searchText) else emptyList() }
.onEach { isSearching = false }
.collectAsStateWithLifecycle()
val wereStocksFound = stocks.isNotEmpty()
Side note, beware of using length >= 3 on your search string. That is completely ignoring code point size.

Jetpack compose and viewModels - Ignore last state value on first composition

How do i ignore the viewModel's last state on the first composition of a composable?
The usecase is the following:
User enters MyDumbComposable
Clicks to add a song to a playlist
When the action is successful, popBack
This is my viewModel
#HiltViewModel
class AddToPlaylistViewModel #Inject constructor(
private val addToPlaylistUseCase: AddToPlaylistUseCase,
) : ViewModel() {
private val _state = mutableStateOf<AddToPlaylistState>(AddToPlaylistState.Initial)
val state: State<AddToPlaylistState> = _state
operator fun invoke(
params: AddToPlaylistParams
) {
addToPlaylistUseCase(params)
.flowOn(Dispatchers.IO)
.onEach { _state.value = it }
.launchIn(viewModelScope)
}
}
This is MyDumbComposable
fun MyDumbComposable(
addToPlaylistViewModel: AddToPlaylistViewModel = hiltViewModel(),
song: Song,
popBack: () -> Unit
) {
if (addToPlaylistViewModel.state.value is AddToPlaylistState.Loaded) {
LaunchedEffect(Unit) {
popBack()
}
}
fun onClick(playlist: PlaylistWithSongs) {
addToPlaylistViewModel(
AddToPlaylistParams(
selected = Selected(listOf(song)),
playlist = playlist.playlist
)
)
}
///...
It works the first time and pops correctly.
However, whenever the user returns to that
composable, the AddToPlaylistViewModel is cached
so the last value is still AddToPlaylistState.Loaded,
meaning it'll pop the screen right away.
I ended up resetting my state using a DisposableEffect:
DisposableEffect(Unit) {
onDispose {
addToPlaylistViewModel.resetState()
}
}

How to disable simultaneous clicks on multiple items in Jetpack Compose List / Column / Row (out of the box debounce?)

I have implemented a column of buttons in jetpack compose. We realized it is possible to click multiple items at once (with multiple fingers for example), and we would like to disable this feature.
Is there an out of the box way to disable multiple simultaneous clicks on children composables by using a parent column modifier?
Here is an example of the current state of my ui, notice there are two selected items and two unselected items.
Here is some code of how it is implemented (stripped down)
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(nestedScrollParams.childScrollState),
) {
viewDataList.forEachIndexed { index, viewData ->
Row(modifier = modifier.fillMaxWidth()
.height(dimensionResource(id = 48.dp)
.background(colorResource(id = R.color.large_button_background))
.clickable { onClick(viewData) },
verticalAlignment = Alignment.CenterVertically
) {
//Internal composables, etc
}
}
Check this solution. It has similar behavior to splitMotionEvents="false" flag. Use this extension with your Column modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.coroutineScope
fun Modifier.disableSplitMotionEvents() =
pointerInput(Unit) {
coroutineScope {
var currentId: Long = -1L
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { pointerInfo ->
when {
pointerInfo.pressed && currentId == -1L -> currentId = pointerInfo.id.value
pointerInfo.pressed.not() && currentId == pointerInfo.id.value -> currentId = -1
pointerInfo.id.value != currentId && currentId != -1L -> pointerInfo.consume()
else -> Unit
}
}
}
}
}
}
Here are four solutions:
Click Debounce (ViewModel)r
For this, you need to use a viewmodel. The viewmodel handles the click event. You should pass in some id (or data) that identifies the item being clicked. In your example, you could pass an id that you assign to each item (such as a button id):
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
class MyViewModel : ViewModel() {
val debounceState = MutableStateFlow<String?>(null)
init {
viewModelScope.launch {
debounceState
.debounce(300)
.collect { buttonId ->
if (buttonId != null) {
when (buttonId) {
ButtonIds.Support -> displaySupport()
ButtonIds.About -> displayAbout()
ButtonIds.TermsAndService -> displayTermsAndService()
ButtonIds.Privacy -> displayPrivacy()
}
}
}
}
}
fun onItemClick(buttonId: String) {
debounceState.value = buttonId
}
}
object ButtonIds {
const val Support = "support"
const val About = "about"
const val TermsAndService = "termsAndService"
const val Privacy = "privacy"
}
The debouncer ignores any clicks that come in within 500 milliseconds of the last one received. I've tested this and it works. You'll never be able to click more than one item at a time. Although you can touch two at a time and both will be highlighted, only the first one you touch will generate the click handler.
Click Debouncer (Modifier)
This is another take on the click debouncer but is designed to be used as a Modifier. This is probably the one you will want to use the most. Most apps will make the use of scrolling lists that let you tap on a list item. If you quickly tap on an item multiple times, the code in the clickable modifier will execute multiple times. This can be a nuisance. While users normally won't tap multiple times, I've seen even accidental double clicks trigger the clickable twice. Since you want to avoid this throughout your app on not just lists but buttons as well, you probably should use a custom modifier that lets you fix this issue without having to resort to the viewmodel approach shown above.
Create a custom modifier. I've named it onClick:
fun Modifier.onClick(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = {
App.debounceClicks {
onClick.invoke()
}
},
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
You'll notice that in the code above, I'm using App.debounceClicks. This of course doesn't exist in your app. You need to create this function somewhere in your app where it is globally accessible. This could be a singleton object. In my code, I use a class that inherits from Application, as this is what gets instantiated when the app starts:
class App : Application() {
override fun onCreate() {
super.onCreate()
}
companion object {
private val debounceState = MutableStateFlow { }
init {
GlobalScope.launch(Dispatchers.Main) {
// IMPORTANT: Make sure to import kotlinx.coroutines.flow.collect
debounceState
.debounce(300)
.collect { onClick ->
onClick.invoke()
}
}
}
fun debounceClicks(onClick: () -> Unit) {
debounceState.value = onClick
}
}
}
Don't forget to include the name of your class in your AndroidManifest:
<application
android:name=".App"
Now instead of using clickable, use onClick instead:
Text("Do Something", modifier = Modifier.onClick { })
Globally disable multi-touch
In your main activity, override dispatchTouchEvent:
class MainActivity : AppCompatActivity() {
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return ev?.getPointerCount() == 1 && super.dispatchTouchEvent(ev)
}
}
This disables multi-touch globally. If your app has a Google Maps, you will want to add some code to to dispatchTouchEvent to make sure it remains enabled when the screen showing the map is visible. Users will use two fingers to zoom on a map and that requires multi-touch enabled.
State Managed Click Handler
Use a single click event handler that stores the state of which item is clicked. When the first item calls the click, it sets the state to indicate that the click handler is "in-use". If a second item attempts to call the click handler and "in-use" is set to true, it just returns without performing the handler's code. This is essentially the equivalent of a synchronous handler but instead of blocking, any further calls just get ignored.
The most simple approach that I found for this issue is to save the click state for each Item on the list, and update the state to 'true' if an item is clicked.
NOTE: Using this approach works properly only in a use-case where the list will be re-composed after the click handling; for example navigating to another Screen when the item click is performed.
Otherwise if you stay in the same Composable and try to click another item, the second click will be ignored and so on.
for example:
#Composable
fun MyList() {
// Save the click state in a MutableState
val isClicked = remember {
mutableStateOf(false)
}
LazyColumn {
items(10) {
ListItem(index = "$it", state = isClicked) {
// Handle the click
}
}
}
}
ListItem Composable:
#Composable
fun ListItem(
index: String,
state: MutableState<Boolean>,
onClick: () -> Unit
) {
Text(
text = "Item $index",
modifier = Modifier
.clickable {
// If the state is true, escape the function
if (state.value)
return#clickable
// else, call onClick block
onClick()
state.value = true
}
)
}
Trying to turn off multi-touch, or adding single click to the modifier, is not flexible enough. I borrowed the idea from #Johann‘s code. Instead of disabling at the app level, I can call it only when I need to disable it.
Here is an Alternative solution:
class ClickHelper private constructor() {
private val now: Long
get() = System.currentTimeMillis()
private var lastEventTimeMs: Long = 0
fun clickOnce(event: () -> Unit) {
if (now - lastEventTimeMs >= 300L) {
event.invoke()
}
lastEventTimeMs = now
}
companion object {
#Volatile
private var instance: ClickHelper? = null
fun getInstance() =
instance ?: synchronized(this) {
instance ?: ClickHelper().also { instance = it }
}
}
}
then you can use it anywhere you want:
Button(onClick = { ClickHelper.getInstance().clickOnce {
// Handle the click
} } ) { }
or:
Text(modifier = Modifier.clickable { ClickHelper.getInstance().clickOnce {
// Handle the click
} } ) { }
fun singleClick(onClick: () -> Unit): () -> Unit {
var latest: Long = 0
return {
val now = System.currentTimeMillis()
if (now - latest >= 300) {
onClick()
latest = now
}
}
}
Then you can use
Button(onClick = singleClick {
// TODO
})
Here is my solution.
It's based on https://stackoverflow.com/a/69914674/7011814
by I don't use GlobalScope (here is an explanation why) and I don't use MutableStateFlow as well (because its combination with GlobalScope may cause a potential memory leak).
Here is a head stone of the solution:
#OptIn(FlowPreview::class)
#Composable
fun <T>multipleEventsCutter(
content: #Composable (MultipleEventsCutterManager) -> T
) : T {
val debounceState = remember {
MutableSharedFlow<() -> Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
val result = content(
object : MultipleEventsCutterManager {
override fun processEvent(event: () -> Unit) {
debounceState.tryEmit(event)
}
}
)
LaunchedEffect(true) {
debounceState
.debounce(CLICK_COLLAPSING_INTERVAL)
.collect { onClick ->
onClick.invoke()
}
}
return result
}
#OptIn(FlowPreview::class)
#Composable
fun MultipleEventsCutter(
content: #Composable (MultipleEventsCutterManager) -> Unit
) {
multipleEventsCutter(content)
}
The first function can be used as a wrapper around your code like this:
MultipleEventsCutter { multipleEventsCutterManager ->
Button(
onClick = { multipleClicksCutter.processEvent(onClick) },
...
) {
...
}
}
And you can use the second one to create your own modifier, like next one:
fun Modifier.clickableSingle(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) = composed(
inspectorInfo = debugInspectorInfo {
name = "clickable"
properties["enabled"] = enabled
properties["onClickLabel"] = onClickLabel
properties["role"] = role
properties["onClick"] = onClick
}
) {
multipleEventsCutter { manager ->
Modifier.clickable(
enabled = enabled,
onClickLabel = onClickLabel,
onClick = { manager.processEvent { onClick() } },
role = role,
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
}
Just add two lines in your styles. This will disable multitouch in whole application:
<style name="AppTheme" parent="...">
...
<item name="android:windowEnableSplitTouch">false</item>
<item name="android:splitMotionEvents">false</item>
</style>

Can I replace produceState with mutableStateOf in the Compose sample project?

The following Code A is from the project.
uiState is created by the delegate produceState, can I use mutableStateOf instead of produceState? If so, how can I write code?
Why can't I use Code B in the project?
Code A
#Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
when {
uiState.cityDetails != null -> {
...
}
#HiltViewModel
class DetailsViewModel #Inject constructor(
private val destinationsRepository: DestinationsRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val cityName = savedStateHandle.get<String>(KEY_ARG_DETAILS_CITY_NAME)!!
val cityDetails: Result<ExploreModel>
get() {
val destination = destinationsRepository.getDestination(cityName)
return if (destination != null) {
Result.Success(destination)
} else {
Result.Error(IllegalArgumentException("City doesn't exist"))
}
}
}
data class DetailsUiState(
val cityDetails: ExploreModel? = null,
val isLoading: Boolean = false,
val throwError: Boolean = false
)
Code B
#Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val cityDetailsResult = viewModel.cityDetails
val uiState=if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
...
uiState is created by the delegate produceState, can I use mutableStateOf instead of produceState? If so, how can I write code?
No, you can't write it using the mutableStateOf (direct initialization not possible). In order to understand why it not possible we need to understand the use of produceState
According to documentation available here
produceState launches a coroutine scoped to the Composition that can
push values into a returned State. Use it to convert non-Compose state
into Compose state, for example bringing external subscription-driven
state such as Flow, LiveData, or RxJava into the Composition.
So basically it is compose way of converting non-Compose state to compose the state.
if you still want to use mutableStateOf you can do something like this
var uiState = remember { mutableStateOf(DetailsUIState())}
LaunchedEffect(key1 = someKey, block = {
uiState = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
})
Note: here someKey might be another variable which handles the recomposition of the state
What is wrong with this approach?
As you can see it's taking another variable someKey to recomposition. and handling it is quite tough compared to produceState
Why can't I use Code B in the project?
The problem with code B is you don't know whether the data is loaded or not while displaying the result. It's not observing the viewModel's data but its just getting the currently available data and based on that it gives the composition.
Imagine if the viewModel is getting data now you will be having UiState with isLoading = true but after some time you get data after a successful API call or error if it fails, at that time the composable function in this case DetailsScreen doesn't know about it at all unless you are observing the Ui state somewhere above this composition and causing this composition to recompose based on newState available.
But in produceState the state of the ui will automatically changed once the suspended network call completes ...

MPAndroid Chart dissapears after calling invalidate() with new data

In my Weather app, I have a MainFragment which has a button that opens a different fragment (SearchFragment) (via replace), allows a user to select a location and then fetches weather data for that location and loads it in various views including an MPAndroid LineChart. My issue is that whenever I come back from the search fragment, although the new data is fetched for the chart and I'm calling chart.notifyDataSetChanged() & chart.invalidate() (also tried chart.postInvalidate() since it was suggested when working on another thread) after the invalidate() is called the chart simply disappears. What am i missing here?
MainFragment:
const val UNIT_SYSTEM_KEY = "UNIT_SYSTEM"
const val LATEST_CURRENT_LOCATION_KEY = "LATEST_CURRENT_LOC"
class MainFragment : Fragment() {
// Lazy inject the view model
private val viewModel: WeatherViewModel by viewModel()
private lateinit var weatherUnitConverter: WeatherUnitConverter
private val TAG = MainFragment::class.java.simpleName
// View declarations
...
// OnClickListener to handle the current weather's "Details" layout expansion/collapse
private val onCurrentWeatherDetailsClicked = View.OnClickListener {
if (detailsExpandedLayout.visibility == View.GONE) {
detailsExpandedLayout.visibility = View.VISIBLE
detailsExpandedArrow.setImageResource(R.drawable.ic_arrow_up_black)
} else {
detailsExpandedLayout.visibility = View.GONE
detailsExpandedArrow.setImageResource(R.drawable.ic_down_arrow)
}
}
// OnClickListener to handle place searching using the Places SDK
private val onPlaceSearchInitiated = View.OnClickListener {
(activity as MainActivity).openSearchPage()
}
// RefreshListener to update the UI when the location settings are changed
private val refreshListener = SwipeRefreshLayout.OnRefreshListener {
Toast.makeText(activity, "calling onRefresh()", Toast.LENGTH_SHORT).show()
swipeRefreshLayout.isRefreshing = false
}
// OnClickListener to allow navigating from this fragment to the settings one
private val onSettingsButtonClicked: View.OnClickListener = View.OnClickListener {
(activity as MainActivity).openSettingsPage()
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = inflater.inflate(R.layout.main_fragment, container, false)
// View initializations
.....
hourlyChart = view.findViewById(R.id.lc_hourly_forecasts)
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setUpChart()
lifecycleScope.launch {
// Shows a lottie animation while the data is being loaded
//scrollView.visibility = View.GONE
//lottieAnimView.visibility = View.VISIBLE
bindUIAsync().await()
// Stops the animation and reveals the layout with the data loaded
//scrollView.visibility = View.VISIBLE
//lottieAnimView.visibility = View.GONE
}
}
#SuppressLint("SimpleDateFormat")
private fun bindUIAsync() = lifecycleScope.async(Dispatchers.Main) {
// fetch current weather
val currentWeather = viewModel.currentWeatherData
// Observe the current weather live data
currentWeather.observe(viewLifecycleOwner, Observer { currentlyLiveData ->
if (currentlyLiveData == null) return#Observer
currentlyLiveData.observe(viewLifecycleOwner, Observer { currently ->
setCurrentWeatherDate(currently.time.toDouble())
// Get the unit system pref's value
val unitSystem = viewModel.preferences.getString(
UNIT_SYSTEM_KEY,
UnitSystem.SI.name.toLowerCase(Locale.ROOT)
)
// set up views dependent on the Unit System pref's value
when (unitSystem) {
UnitSystem.SI.name.toLowerCase(Locale.ROOT) -> {
setCurrentWeatherTemp(currently.temperature)
setUnitSystemImgView(unitSystem)
}
UnitSystem.US.name.toLowerCase(Locale.ROOT) -> {
setCurrentWeatherTemp(
weatherUnitConverter.convertToFahrenheit(
currently.temperature
)
)
setUnitSystemImgView(unitSystem)
}
}
setCurrentWeatherSummaryText(currently.summary)
setCurrentWeatherSummaryIcon(currently.icon)
setCurrentWeatherPrecipProb(currently.precipProbability)
})
})
// fetch the location
val weatherLocation = viewModel.weatherLocation
// Observe the location for changes
weatherLocation.observe(viewLifecycleOwner, Observer { locationLiveData ->
if (locationLiveData == null) return#Observer
locationLiveData.observe(viewLifecycleOwner, Observer { location ->
Log.d(TAG,"location update = $location")
locationTxtView.text = location.name
})
})
// fetch hourly weather
val hourlyWeather = viewModel.hourlyWeatherEntries
// Observe the hourly weather live data
hourlyWeather.observe(viewLifecycleOwner, Observer { hourlyLiveData ->
if (hourlyLiveData == null) return#Observer
hourlyLiveData.observe(viewLifecycleOwner, Observer { hourly ->
val xAxisLabels = arrayListOf<String>()
val sdf = SimpleDateFormat("HH")
for (i in hourly.indices) {
val formattedLabel = sdf.format(Date(hourly[i].time * 1000))
xAxisLabels.add(formattedLabel)
}
setChartAxisLabels(xAxisLabels)
})
})
// fetch weekly weather
val weeklyWeather = viewModel.weeklyWeatherEntries
// get the timezone from the prefs
val tmz = viewModel.preferences.getString(LOCATION_TIMEZONE_KEY, "America/Los_Angeles")!!
// observe the weekly weather live data
weeklyWeather.observe(viewLifecycleOwner, Observer { weeklyLiveData ->
if (weeklyLiveData == null) return#Observer
weeklyLiveData.observe(viewLifecycleOwner, Observer { weatherEntries ->
// update the recyclerView with the new data
(weeklyForecastRCV.adapter as WeeklyWeatherAdapter).updateWeeklyWeatherData(
weatherEntries, tmz
)
for (day in weatherEntries) { //TODO:sp replace this with the full list once the repo issue is fixed
val zdtNow = Instant.now().atZone(ZoneId.of(tmz))
val dayZdt = Instant.ofEpochSecond(day.time).atZone(ZoneId.of(tmz))
val formatter = DateTimeFormatter.ofPattern("MM-dd-yyyy")
val formattedNowZtd = zdtNow.format(formatter)
val formattedDayZtd = dayZdt.format(formatter)
if (formattedNowZtd == formattedDayZtd) { // find the right week day whose data we want to use for the UI
initTodayData(day, tmz)
}
}
})
})
// get the hourly chart's computed data
val hourlyChartLineData = viewModel.hourlyChartData
// Observe the chart's data
hourlyChartLineData.observe(viewLifecycleOwner, Observer { lineData ->
if(lineData == null) return#Observer
hourlyChart.data = lineData // Error due to the live data value being of type Unit
})
return#async true
}
...
private fun setChartAxisLabels(labels: ArrayList<String>) {
// Populate the X axis with the hour labels
hourlyChart.xAxis.valueFormatter = IndexAxisValueFormatter(labels)
}
/**
* Sets up the chart with the appropriate
* customizations.
*/
private fun setUpChart() {
hourlyChart.apply {
description.isEnabled = false
setNoDataText("Data is loading...")
// enable touch gestures
setTouchEnabled(true)
dragDecelerationFrictionCoef = 0.9f
// enable dragging
isDragEnabled = true
isHighlightPerDragEnabled = true
setDrawGridBackground(false)
axisRight.setDrawLabels(false)
axisLeft.setDrawLabels(false)
axisLeft.setDrawGridLines(false)
xAxis.setDrawGridLines(false)
xAxis.isEnabled = true
// disable zoom functionality
setScaleEnabled(false)
setPinchZoom(false)
isDoubleTapToZoomEnabled = false
// disable the chart's legend
legend.isEnabled = false
// append extra offsets to the chart's auto-calculated ones
setExtraOffsets(0f, 0f, 0f, 10f)
data = LineData()
data.isHighlightEnabled = false
setVisibleXRangeMaximum(6f)
setBackgroundColor(resources.getColor(R.color.bright_White, null))
}
// X Axis setup
hourlyChart.xAxis.apply {
position = XAxis.XAxisPosition.BOTTOM
textSize = 14f
setDrawLabels(true)
setDrawAxisLine(false)
granularity = 1f // one hour
spaceMax = 0.2f // add padding start
spaceMin = 0.2f // add padding end
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
typeface = resources.getFont(R.font.work_sans)
}
textColor = resources.getColor(R.color.black, null)
}
// Left Y axis setup
hourlyChart.axisLeft.apply {
setDrawLabels(false)
setDrawGridLines(false)
setPosition(YAxis.YAxisLabelPosition.OUTSIDE_CHART)
isEnabled = false
isGranularityEnabled = true
// temperature values range (higher than probable temps in order to scale down the chart)
axisMinimum = 0f
axisMaximum = when (getUnitSystemValue()) {
UnitSystem.SI.name.toLowerCase(Locale.ROOT) -> 50f
UnitSystem.US.name.toLowerCase(Locale.ROOT) -> 150f
else -> 50f
}
}
// Right Y axis setup
hourlyChart.axisRight.apply {
setDrawGridLines(false)
isEnabled = false
}
}
}
ViewModel class:
class WeatherViewModel(
private val forecastRepository: ForecastRepository,
private val weatherUnitConverter: WeatherUnitConverter,
context: Context
) : ViewModel() {
private val appContext = context.applicationContext
// Retrieve the sharedPrefs
val preferences:SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(appContext)
// This will run only when currentWeatherData is called from the View
val currentWeatherData = liveData {
val task = viewModelScope.async { forecastRepository.getCurrentWeather() }
emit(task.await())
}
val hourlyWeatherEntries = liveData {
val task = viewModelScope.async { forecastRepository.getHourlyWeather() }
emit(task.await())
}
val weeklyWeatherEntries = liveData {
val task = viewModelScope.async {
val currentDateEpoch = LocalDate.now().toEpochDay()
forecastRepository.getWeekDayWeatherList(currentDateEpoch)
}
emit(task.await())
}
val weatherLocation = liveData {
val task = viewModelScope.async(Dispatchers.IO) {
forecastRepository.getWeatherLocation()
}
emit(task.await())
}
val hourlyChartData = liveData {
val task = viewModelScope.async(Dispatchers.Default) {
// Build the chart data
hourlyWeatherEntries.observeForever { hourlyWeatherLiveData ->
if(hourlyWeatherLiveData == null) return#observeForever
hourlyWeatherLiveData.observeForever {hourlyWeather ->
createChartData(hourlyWeather)
}
}
}
emit(task.await())
}
/**
* Creates the line chart's data and returns them.
* #return The line chart's data (x,y) value pairs
*/
private fun createChartData(hourlyWeather: List<HourWeatherEntry>?): LineData {
if(hourlyWeather == null) return LineData()
val unitSystemValue = preferences.getString(UNIT_SYSTEM_KEY, "si")!!
val values = arrayListOf<Entry>()
for (i in hourlyWeather.indices) { // init data points
// format the temperature appropriately based on the unit system selected
val hourTempFormatted = when (unitSystemValue) {
UnitSystem.SI.name.toLowerCase(Locale.ROOT) -> hourlyWeather[i].temperature
UnitSystem.US.name.toLowerCase(Locale.ROOT) -> weatherUnitConverter.convertToFahrenheit(
hourlyWeather[i].temperature
)
else -> hourlyWeather[i].temperature
}
// Create the data point
values.add(
Entry(
i.toFloat(),
hourTempFormatted.toFloat(),
appContext.resources.getDrawable(determineSummaryIcon(hourlyWeather[i].icon), null)
)
)
}
Log.d("MainFragment viewModel", "$values")
// create a data set and customize it
val lineDataSet = LineDataSet(values, "")
val color = appContext.resources.getColor(R.color.black, null)
val offset = MPPointF.getInstance()
offset.y = -35f
lineDataSet.apply {
valueFormatter = YValueFormatter()
setDrawValues(true)
fillDrawable = appContext.resources.getDrawable(R.drawable.gradient_night_chart, null)
setDrawFilled(true)
setDrawIcons(true)
setCircleColor(color)
mode = LineDataSet.Mode.HORIZONTAL_BEZIER
this.color = color // line color
iconsOffset = offset
lineWidth = 3f
valueTextSize = 9f
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
valueTypeface = appContext.resources.getFont(R.font.work_sans_medium)
}
}
// create a LineData object using our LineDataSet
val data = LineData(lineDataSet)
data.apply {
setValueTextColor(R.color.colorPrimary)
setValueTextSize(15f)
}
return data
}
private fun determineSummaryIcon(icon: String): Int {
return when (icon) {
"clear-day" -> R.drawable.ic_sun
"clear-night" -> R.drawable.ic_moon
"rain" -> R.drawable.ic_precipitation
"snow" -> R.drawable.ic_snowflake
"sleet" -> R.drawable.ic_sleet
"wind" -> R.drawable.ic_wind_speed
"fog" -> R.drawable.ic_fog
"cloudy" -> R.drawable.ic_cloud_coverage
"partly-cloudy-day" -> R.drawable.ic_cloudy_day
"partly-cloudy-night" -> R.drawable.ic_cloudy_night
"hail" -> R.drawable.ic_hail
"thunderstorm" -> R.drawable.ic_thunderstorm
"tornado" -> R.drawable.ic_tornado
else -> R.drawable.ic_sun
}
}
}
LazyDeferred:
fun<T> lazyDeferred(block: suspend CoroutineScope.() -> T) : Lazy<Deferred<T>> {
return lazy {
GlobalScope.async {
block.invoke(this)
}
}
}
ScopedFragment :
abstract class ScopedFragment : Fragment(), CoroutineScope {
private lateinit var job: Job
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
job = Job()
}
override fun onDestroy() {
job.cancel()
super.onDestroy()
}
}
Without the entire environment it's really hard for me to help you debug the whole thing but I am happy to provide you with a couple of things that seem a little bit off at a first glance.
First of all I would avoid managing all CoroutinesScopes and lifecycles by yourself and it's easy to get it wrong. So I would rely on what the Android team has already done. Take a quick look here, it's really easy to setup and use. The dev experience is great.
Posting Deferred on a LiveData and awaiting on the view side looks like a code smell…
What if there's a network error?
It would result in an exception or cancellation exception being thrown.
What if the task was already perform and causes some type of UI consistency problem? These are a couple of edge cases I would not really want to handle.
Just observe a LiveData since it is its main purpose: it's a value holder and it's intended to live throught several lifecycle events in the Fragment. So once view is recreated the value is ready in the LiveData inside the ViewModel.
Your lazyDeferred function is quite smart but in the Android world it's also dangerous. Those coroutines don't live inside any lifecycle-controlled scope so they have a really high chance to end up being leaked. And trust me, you don't want any coroutines being leaked since they continue their work even after viewmodel and fragment destruction which is something you definetely don't want.
All of these are easily fixable by using the dependency I've mentioned before, which I'll paste here once more
Here's a snippet on how you could use those utilities in your ViewModel to ensure the lifecycle of things nor coroutines are causing any issues:
class WeatherViewModel(
private val forecastRepository: ForecastRepository,
context: Context
) : ViewModel() {
private val appContext = context.applicationContext
// Retrieve the sharedPrefs
val preferences:SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(appContext)
// This will run only when currentWeatherData is called from the View
val currentWeatherData = liveData {
val task = viewModelScope.async { forecastRepository.getCurrentWeather() }
emit(task.await())
}
val hourlyWeatherEntries = liveData {
val task = viewModelScope.async { forecastRepository.getHourlyWeather() }
emit(task.await())
}
val weeklyWeatherEntries = liveData {
val task = viewModelScope.async {
val currentDateEpoch = LocalDate.now().toEpochDay()
forecastRepository.getWeekDayWeatherList(currentDateEpoch)
}
emit(task.await())
}
val weatherLocation = liveData {
val task = viewModelScope.async(Dispatchers.IO) {
forecastRepository.getWeatherLocation()
}
emit(task.await())
}
}
By using the following approach all network calls are performed in a parallel fashion and they are all tied to the viewModelScope without writing a single line of handling the CoroutineScope's life. When the ViewModel dies, so will the scope. when the view gets re-created the routines won't execute twice and values will be ready to read.
Regarding the configuration of the chart: I would highly suggest you configure the chart as soon as you have created the view, since it is highly tied together. Configuration is something you want to do just once and may cause visual bugs if some instructions are executed more than once (which I believe it could be happening to you), just saying so because I've had problems with MPAndroid using a Piechart.
More on the chart: All of the logic of constructing the LineData would be better off on a background thread and being exposed through a LiveData in the ViewModel side like you would do with all of the other
val property = liveData {
val deferred = viewModelScope.async(Dispatchers.Default) {
// Heavy building logic like:
createChartData()
}
emit(deferred.await())
}
Pro Kotlin tip: Avoid repeating yourself during those long MPAndroid configurations functions.
Instead of:
view.configureThis()
view.configureThat()
view.enabled = true
Do:
view.apply {
configureThis()
configureThat()
enabled = true
}
I'm sad that I can just give you these hints and being unable to exactly pin-point what your issue is since the bug is heavily related to what is happenning throughout the lifecycle evolution of the runtime but hopefuly this is going to be useful
Answering your comment
If one of your data-streams (LiveData) is dependent on what another data-stream (another LiveData) is going to emit you are looking for LiveData.map and LiveData.switchMap operations.
I imagine that hourlyWeatherEntries is going to be emitting values from time to time.
In that case you can use LiveData.switchMap.
What this does is that everytime the source LiveData emits a value, you're going to get a callback and you are expected to return a new live data with the new value.
You could arrange something like the following:
val hourlyChartData = hourlyWeatherEntries.switchMap { hourlyWeather ->
liveData {
val task = viewModelScope.async(Dispatchers.IO) {
createChartData(hourlyWeather)
}
emit(task.await())
}
}
Using this approach has the benefit that it is completely lazy. That means that NO COMPUTATION is going to take place UNLESS data is being actively observed by some lifecycleOwner. That just means that no callbacks are being triggered unless data is observed in the Fragment
Further explanation on map and switchMap
Since we need to do some asynchronous computation that we don't know when it's going to be done we can't use map. map applies a linear transformation between LiveDatas. Check this out:
val liveDataOfNumbers = liveData { // Returns a LiveData<Int>
viewModelScope.async {
for(i in 0..10) {
emit(i)
delay(1000)
}
}
}
val liveDataOfDoubleNumbers = liveDataOfNumbers.map { number -> number * 2}
This is really useful when the computation is linear and simple. What is happening behind the hood is that the library is handling observing and emitting values for you by means of a MediatorLiveData. What happens here is that whenever liveDataOfNumbers emits a value and liveDataOfDoubleNumbers is being observed the callback gets applied; so the liveDataOfDoubleNumbers is emitting: 0, 2, 4, 6…
The snippet above is equivalent to the following:
val liveDataOfNumbers = liveData { // Returns a LiveData<Int>
viewModelScope.async {
for(i in 0..10) {
emit(i)
delay(1000)
}
}
}
val liveDataOfDoubleNumbers = MediatorLiveData<Int>().apply {
addSource(liveDataOfNumbers) { newNumber ->
// Update MediatorLiveData value when liveDataOfNumbers creates a new number
value = newNumber * 2
}
}
But just using map is much much simpler.
Fantastic!!
Now going to your use-case. Your computation is linear but we want to defer that work to a background coroutine. So we can't exactly tell when something is going to end.
For these use-cases they have created the switchMap operator. What it does it's just the same as map but wraps everything within another LiveData. The intermediate LiveData just acts as a container for the response that is going to come from the coroutine.
So what ends up happening is:
Your coroutine publishes into intermediateLiveData
switchMap does something similar to:
return MediatorLiveData().apply {
// intermediateLiveData is what your callback generates
addSource(intermediateLiveData) { newValue -> this.value = newValue }
} as LiveData
Summing up:
1. Coroutine passes value to intermediateLiveData
2. intermediateLiveData passes value to the hourlyChartData
3. hourlyChartData passes value to the UI
And everything without adding or removing observeForever
Since the liveData {…} is a builder to help us create asynchronous LiveDatas without dealing with the hassle of instantiating them we can use it so our switchMap callback is less verbose.
The function liveData returns a live data of type LiveData<T>. If your repository call already returns a LiveData it's really simple!
val someLiveData = originalLiveData.switchMap { newValue ->
someRepositoryCall(newValue).map { returnedRepoValue -> /*your transformation code here*/}
}
Separate the setupChart and setData logics. Setup chart once out of the observer, inside the observer setData and after that call invalidate().
Try commenting out the invalidate() part and wherever you are calling your search function before it try yourlineChart.clear(); or yourlineChart.clearValues();. This will clear the previous values of the chart and will form chart with new values. So, invalidate() and chart.notifyDataSetChanged() won't not be necessary and it should solve your problem.

Categories

Resources