I'm reading about Navigation in Jetpack Compose, and found this example I don't understand.
From the docs:
By using the saveState and restoreState flags, the state and back stack of that item is correctly saved and restored as you swap between bottom navigation items.
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNavigation {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
BottomNavigationItem(
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
label = { Text(stringResource(screen.resourceId)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavHost(navController, startDestination = Screen.Profile.route, Modifier.padding(innerPadding)) {
composable(Screen.Profile.route) { Profile(navController) }
composable(Screen.FriendsList.route) { FriendsList(navController) }
}
}
Specifically, I don't understand how the back stack can be saved if clicking an item in the bottom bar pops the navigation stack to the root.
I would imagine a journey like:
User moves to /FriendsList/Friend(A)/Friend(B)/Friend(C)
User clicks Profile button, resetting the navigation stack to /Profile
User clicks FriendsList button.
Based on the explanation, I would expect the navigation stack to be re-set to /FriendsList/FriendA/FriendB/FriendC, even though the onClick listener seems to set the stack to /FriendsList?
I really don't understand how this can happen, how does the navigation controller link the route to the entire navigation sub-stack? Is item.route changing state containing the full route to /FriendsList/Friend(A)/Friend(B)/Friend(C), or is something else going on? Or do I understand the example wrong?
I suspect maybe the underlying mechanism is that FriendsList contains a nested navigation graph, since the example doesn't actually show any Friend route definitions. The state of this entire nested graph is contained somehow, i.e., something like /FriendsList{FriendA/FriendB/FriendC}, and a move to /FriendsList will unpack this navigation stack. Is that kind of how it works?
The code in the example you gave has only 1 navHost and 1 navController so it is not possible to save the FriendList back stack seperated from the rest back stack.
To achive this behavior you need to declare nested navHosts. Each of them with his own navController and thats it. All the rest is working automatically.
Here is an example
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
Scaffold(
bottomBar = {
BottomNavigation {
items.forEach { screen ->
BottomNavigationItem(
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
label = { Text(stringResource(screen.resourceId)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavHost(
navController,
startDestination = Screen.ProfileNavHost.route,
Modifier.padding(innerPadding)
) {
composable(Screen.ProfileNavHost.route) {
val profileNavController = rememberNavController()
NavHost(
navController = profileNavController,
startDestination = Screen.ProfileRoot.route
) {
composable(Screen.ProfileRoot.route) {
ProfileRoot(navController)
}
composable(Screen.ProfileScreen.route) {
ProfileScreen(navController)
}
}
}
composable(Screen.FriendsListNavHost.route) {
val friendsListNavController = rememberNavController()
NavHost(
navController = friendsListNavController,
startDestination = Screen.FriendsListRoot.route
) {
composable(Screen.FriendsListRoot.route) {
FriendsList(navController)
}
composable(Screen.FriendsListFriend.route) {
FriendsListFriend(navController)
}
}
}
}
}
Related
I have a map component that contains some clickable overlays on the map. Users can click and unclick the overlays on the map to select them and when they do so the app loads some data based on the overlays that are currently selected.
The current structure is as follows:
User clicks the map which executes a function passed to the map as a prop, which takes the current value of the neighborhoods and either adds or removes them from the query string.
The function executes a history.push()
I use a useEffect checking the value of the query param neighborhood and send a request to the backend to fetch the listings if the values have changed.
My issue is that when the user clicks on the map, the function executes but the value pushed to the params is never updated, causing the logic to fail the next time the user clicks on the map.
Relevant Snippets of Code are as follows:
history/param variables:
const { region, state, neighborhood, transactiontype } = useParams();
const location = useLocation();
const { pathname, search } = location;
Function that is passed down to the child map component:
const updateChildComponentHandler = (dataFromChild, addorRemove) => {
SetLastSetter("map");
SetNeighborhoodTypeSelected("custom");
// if a new neighborhood is added, just taking the existing string and adding &`neighborhood`
if (addorRemove === "add") {
let newGroup = ""
if (neighborhood !== "any") {
newGroup = `${neighborhood}&${dataFromChild}`;
SetMapChangeType("add");
}
// if no neighborhood was initially selected, just replacing the "any" with the proper neighborhood
if (neighborhood === "any") {
SetMapChangeType("add");
newGroup = `${dataFromChild}`
}
// pushing the new parameter string
const newPath = pathname.replace(neighborhood, newGroup);
history.push(`${newPath}${search}`);
}
// same concept as above, just removing the neighborhood from the string if it is removed from the map
if (addorRemove === "remove") {
let newGroup;
if (neighborhood !== dataFromChild) {
newGroup = neighborhood.replace(`&${dataFromChild}`, "")
SetMapChangeType("delete");
}
if (neighborhood === dataFromChild) {
newGroup = "any";
SetMapChangeType("add");
}
if (neighborhood.split("&")[0] === dataFromChild && neighborhood !== dataFromChild) {
newGroup = neighborhood.replace(`${dataFromChild}&`, "")
SetMapChangeType("delete");
}
const newPath = pathname.replace(neighborhood, newGroup);
const newerPath = `${newPath}${search}`;
history.push(newerPath);
deleteListings(dataFromChild);
}
SetUpdateMap(true);
}
UseEffect Logic:
useEffect(() => {
const func = async () => {
let neighborhoodParams;
if (neighborhood !== "any") {
neighborhoodParams = neighborhood.replace("%20", " ").split("&")
}
if (neighborhood === "any") {
neighborhoodParams = [];
neighborhoodParams[0] = "any";
}
const neighborhoods = neighborhood.split("%20").join("_");
SetNeighborhoodParams(neighborhoodParams);
SetNeighborhoodParamString(neighborhoods);
if (mapChangeType === "add") {
if (neighborhoodParams.length > 0) {
if (!requestMulti) {
await fetchProperties(transactiontype, neighborhoodParams[neighborhoodParams.length - 1].split(" ").join("_"), filteredRegion, state, filters, "single")
}
if (requestMulti) {
await fetchProperties(transactiontype, neighborhoods, filteredRegion, state, filters, "multi")
SetRequestMulti(false);
}
}
}
}
func();
}, [neighborhood]);
The issue I am experiencing is that when the map initially loads, the neighborhood is set to "any". The first time a user clicks an overlay for a neighborhood, the correct data is sent from the map and the map/data requests update and the URL parameter up top shows the new neighborhood. However, the second time a user clicks, the value of { neighborhood } is not updated and is still set to "any", so the function just replaces the value of { neighborhood } rather than adding it on as per above. I previously coded this with class components and am trying to convert it to hooks, but it seems like there is some dissonance that is causing the map component not to have access to the updated history variable. I am new to react/hooks, and appreciate if anyone could lend some advice.
Thanks!
I want to be able to click on a list and make the map flyTo the position of a marker and open its popup. I'm able to do so as long as the marker has an identical position (not spiderfied). I've made a script to find the markerCluster which contains the marker, but I can't trigger its click method, to make the marker accessable.
// if marker is not accessible
Object.values(mapRef.current._targets).forEach(_target => {
if (_target._markers) {
const wantedMarker = _target._markers.find(_marker => {
return (
_marker.options.id === someId
);
});
if (wantedMarker) {
_target.click() // _target.click() is not a function
Without a living code example, I can't confirm that wantedMarker is indeed the instance of the marker you want. But if its, .click() is indeed not a function on that. However, you can get the marker's popup, if there is one, and open that programatically:
const popup = wantedMarker.getPopup();
if (popup){
popup.openOn(map) // you'll need a reference to the L.map instance for this
}
Solved it by looking for "_icon" in all parent elements. Elements hidden by markerCluster does not have this property. The _icon element has click events, so I used that to open the marker clusters.
const openMarkerClustersAndMarkerPopup = (allMarkersRef, markerId) => {
const markerElementRef = allMarkersRef.current[markerId];
if (markerElementRef._icon) {
markerElementRef._icon.click();
} else {
const clusters = [];
let _currentElement = markerElementRef;
while (_currentElement.__parent) {
clusters.push(_currentElement);
if (_currentElement._icon) {
break;
}
_currentElement = _currentElement.__parent;
}
// Trigger markercluster and marker by starting with top markercluster
for (let i = clusters.length - 1; i >= 0; i--) {
const _icon = clusters[i]._icon;
_icon && _icon.click();
}
}
};
This seems to be a very awkward problem:
I am accessing my modal.service.ts using the following code:
this.modalService.add('test');
My modal.service.ts looks like the following:
import { Injectable } from '#angular/core';
#Injectable({ providedIn: 'root' })
export class ModalService {
private modals: any[] = [];
add(modal: any) {
// add modal to array of active modals
this.modals.push(modal);
}
remove(id: string) {
// remove modal from array of active modals
this.modals = this.modals.filter(x => x.id !== id);
}
open(id: string) {
// open modal specified by id
const modal = this.modals.find(x => x.id === id);
console.log(this.modals)
console.log(this.modals[0])
//modal.open();
}
close(id: string) {
// close modal specified by id
const modal = this.modals.find(x => x.id === id);
modal.close();
}
}
Why does console.log(this.modals[0]) give me undefined when this.modals gives me an output of the array with 'test' being at pos 0?
This is the console output:
It is a problem with (or feature of) browser's console. It shows that 0th element of this.modals is "test" because at the moment of inspecting it is. But at the moment of executing it is empty.
{
const anArray = [];
console.log(anArray);
console.log(anArray[0]);
anArray.push("foo");
}
// Browser's output:
// >>> [] <-- But expanded it will show [0: "foo"].
// >>> undefined
{
const anArray = [];
anArray.push("foo");
console.log(anArray);
console.log(anArray[0]);
}
// Browser's output:
// >>> ["foo"] <-- See. Already pre-filled on display.
// >>> foo
So what you actually have is a race condition. Your this.modals is filled after open is called.
I am creating a multipage application. When the user navigates to one of my pages componentDidMount triggers and currently generates 4 random numbers within a range I have specified and sends them through my reducer. This eventually comes back in the form of an array.
Within componentDidMount I have a loop to call my generateRandomNumber function four times. However, if I go to log the output array I get a blank array, which is the initial state for said reducer.
Logging the array in componentDidUpdate, or anywhere after the mount returns what I want it to but that doesn't help me in checking for duplicates as I cannot check for duplicates if I am checking against a blank array. Everything should be without delay, but there seems to be an issue with trying to read from this.props.currentUserList from within componentDidMount.
I need to verify that I am not adding two of the same user into my array, but I need the pre-check to all be done by the time this appears on-screen.
//gives me a random user from my array
generateRandomUser() {
return myArray[Math.floor(Math.random() * 100) + 1]
}
//sends 4 randomUsers through my actions to my reducers
generateNewID() {
let amountOfOptions = 4;
while (amountOfOptions > 0) {
let randomUser = this.generateRandomUser();
if (this.props.currentUserList.length === 0) {
this.props.setCurrentUser(randomUser);
} else {
//this never fires as currentUserList is somehow still == []
this.checkForDuplicates();
}
amountOfOptions--;
}
}
componentDidMount() {
this.generateNewID()
console.log(this.props.currentUserList)
//returns []
}
componentDidUpdate() {
console.log(this.props.currentUserList)
// returns [
// user2: {
// name: 'name'
// },
// user4: {
// name: 'name'
// },
// user28: {
// name: 'name'
// },
// user92: {
// name: 'name'
// },
// ]
}
I am not sure about the implementation of the array. However if the required output is to get 4 random users then instead of of calling setcurrentuser 4 times, you can generate 4 users and then set them at once.
generateNewID() {
let amountOfOptions = 4;
let randomUserList = [];
while (amountOfOptions > 0) {
let randomUser = this.generateRandomUser();
if (randomUserList.length === 0) {
randomUserList.push(randomUser);
} else {
//this never fires as currentUserList is somehow still == []
this.checkForDuplicates();
}
amountOfOptions--;
}
this.setCurrentUser(randomUserList);
}
Trying to essentially accomplish this https://github.com/elgerlambert/redux-localstorage which is for Redux but do it for Mobx. And preferably would like to use sessionStorage. Is there an easy way to accomplish this with minimal boilerplate?
The easiest way to approach this would be to have a mobx "autorun" triggered whenever any observable property changes. To do that, you could follow my answer to this question.
I'll put some sample code here that should help you get started:
function autoSave(store, save) {
let firstRun = true;
mobx.autorun(() => {
// This code will run every time any observable property
// on the store is updated.
const json = JSON.stringify(mobx.toJS(store));
if (!firstRun) {
save(json);
}
firstRun = false;
});
}
class MyStore {
#mobx.observable prop1 = 999;
#mobx.observable prop2 = [100, 200];
constructor() {
this.load();
autoSave(this, this.save.bind(this));
}
load() {
if (/* there is data in sessionStorage */) {
const data = /* somehow get the data from sessionStorage or anywhere else */;
mobx.extendObservable(this, data);
}
}
save(json) {
// Now you can do whatever you want with `json`.
// e.g. save it to session storage.
alert(json);
}
}
Turns out you can do this in just a few lines of code:
const store = observable({
players: [
"Player 1",
"Player 2",
],
// ...
})
reaction(() => JSON.stringify(store), json => {
localStorage.setItem('store',json);
}, {
delay: 500,
});
let json = localStorage.getItem('store');
if(json) {
Object.assign(store, JSON.parse(json));
}
Boom. No state lost when I refresh the page. Saves every 500ms if there was a change.
Posting the example from here: https://mobx.js.org/best/store.html
This shows a cleaner method of detecting value changes, though not necessarily local storage.
import {observable, autorun} from 'mobx';
import uuid from 'node-uuid';
export class TodoStore {
authorStore;
transportLayer;
#observable todos = [];
#observable isLoading = true;
constructor(transportLayer, authorStore) {
this.authorStore = authorStore; // Store that can resolve authors for us
this.transportLayer = transportLayer; // Thing that can make server requests for us
this.transportLayer.onReceiveTodoUpdate(updatedTodo => this.updateTodoFromServer(updatedTodo));
this.loadTodos();
}
/**
* Fetches all todo's from the server
*/
loadTodos() {
this.isLoading = true;
this.transportLayer.fetchTodos().then(fetchedTodos => {
fetchedTodos.forEach(json => this.updateTodoFromServer(json));
this.isLoading = false;
});
}
/**
* Update a todo with information from the server. Guarantees a todo
* only exists once. Might either construct a new todo, update an existing one,
* or remove an todo if it has been deleted on the server.
*/
updateTodoFromServer(json) {
var todo = this.todos.find(todo => todo.id === json.id);
if (!todo) {
todo = new Todo(this, json.id);
this.todos.push(todo);
}
if (json.isDeleted) {
this.removeTodo(todo);
} else {
todo.updateFromJson(json);
}
}
/**
* Creates a fresh todo on the client and server
*/
createTodo() {
var todo = new Todo(this);
this.todos.push(todo);
return todo;
}
/**
* A todo was somehow deleted, clean it from the client memory
*/
removeTodo(todo) {
this.todos.splice(this.todos.indexOf(todo), 1);
todo.dispose();
}
}
export class Todo {
/**
* unique id of this todo, immutable.
*/
id = null;
#observable completed = false;
#observable task = "";
/**
* reference to an Author object (from the authorStore)
*/
#observable author = null;
store = null;
/**
* Indicates whether changes in this object
* should be submitted to the server
*/
autoSave = true;
/**
* Disposer for the side effect that automatically
* stores this Todo, see #dispose.
*/
saveHandler = null;
constructor(store, id=uuid.v4()) {
this.store = store;
this.id = id;
this.saveHandler = reaction(
// observe everything that is used in the JSON:
() => this.asJson,
// if autoSave is on, send json to server
(json) => {
if (this.autoSave) {
this.store.transportLayer.saveTodo(json);
}
}
);
}
/**
* Remove this todo from the client and server
*/
delete() {
this.store.transportLayer.deleteTodo(this.id);
this.store.removeTodo(this);
}
#computed get asJson() {
return {
id: this.id,
completed: this.completed,
task: this.task,
authorId: this.author ? this.author.id : null
};
}
/**
* Update this todo with information from the server
*/
updateFromJson(json) {
// make sure our changes aren't send back to the server
this.autoSave = false;
this.completed = json.completed;
this.task = json.task;
this.author = this.store.authorStore.resolveAuthor(json.authorId);
this.autoSave = true;
}
dispose() {
// clean up the observer
this.saveHandler();
}
}
Here, you can use my code, although it only supports localStorage you should be able to modify it quite easily.
https://github.com/nightwolfz/mobx-storage