Skip to content

Commit 2f3d862

Browse files
authored
fix: view lifecycle handling (#524)
1 parent d3745c9 commit 2f3d862

File tree

15 files changed

+973
-605
lines changed

15 files changed

+973
-605
lines changed

CONTRIBUTING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ yarn run example detox:test:ios-release
143143
```
144144

145145
Android:
146+
147+
> [!NOTE]
148+
> Create emulator named "Android_Emulator" first if you don't have one already:
146149
```bash
147150
yarn run example detox:test:android-release
148151
```

android/src/main/java/com/google/android/react/navsdk/NavViewManager.java

Lines changed: 71 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import com.google.android.libraries.navigation.StylingOptions;
3636
import java.lang.ref.WeakReference;
3737
import java.util.HashMap;
38+
import java.util.HashSet;
3839
import java.util.Map;
3940
import java.util.Objects;
4041

@@ -52,6 +53,9 @@ public class NavViewManager extends SimpleViewManager<FrameLayout> {
5253
// Cache the latest options per view so deferred fragment creation uses fresh values.
5354
private final HashMap<Integer, ReadableMap> mapOptionsCache = new HashMap<>();
5455

56+
// Track views with pending fragment creation attempts.
57+
private final HashSet<Integer> pendingFragments = new HashSet<>();
58+
5559
private ReactApplicationContext reactContext;
5660

5761
public static synchronized NavViewManager getInstance(ReactApplicationContext reactContext) {
@@ -187,6 +191,9 @@ public void onDropViewInstance(@NonNull FrameLayout view) {
187191

188192
int viewId = view.getId();
189193

194+
pendingFragments.remove(viewId);
195+
mapOptionsCache.remove(viewId);
196+
190197
Choreographer.FrameCallback frameCallback = frameCallbackMap.remove(viewId);
191198
if (frameCallback != null) {
192199
Choreographer.getInstance().removeFrameCallback(frameCallback);
@@ -196,7 +203,6 @@ public void onDropViewInstance(@NonNull FrameLayout view) {
196203
if (activity == null) return;
197204

198205
WeakReference<IMapViewFragment> weakReference = fragmentMap.remove(viewId);
199-
mapOptionsCache.remove(viewId);
200206
if (weakReference != null) {
201207
IMapViewFragment fragment = weakReference.get();
202208
if (fragment != null && fragment.isAdded()) {
@@ -219,7 +225,10 @@ public void setMapOptions(FrameLayout view, @NonNull ReadableMap mapOptions) {
219225
return;
220226
}
221227

222-
scheduleFragmentTransaction(view, mapOptions);
228+
if (!pendingFragments.contains(viewId)) {
229+
pendingFragments.add(viewId);
230+
scheduleFragmentTransaction(view);
231+
}
223232
}
224233

225234
/** Map the "create" command to an integer */
@@ -641,33 +650,43 @@ public Map<String, Object> getExportedCustomDirectEventTypeConstants() {
641650
return (Map) eventTypeConstants;
642651
}
643652

644-
private void scheduleFragmentTransaction(
645-
@NonNull FrameLayout root, @NonNull ReadableMap mapOptions) {
646-
647-
// Commit the fragment transaction after view is added to the view hierarchy.
648-
root.post(() -> tryCommitFragmentTransaction(root, mapOptions));
653+
private void scheduleFragmentTransaction(@NonNull FrameLayout root) {
654+
root.post(() -> tryCommitFragmentTransaction(root));
649655
}
650656

651657
/** Attempt to create/attach the fragment once the parent view has a real size. */
652-
private void tryCommitFragmentTransaction(
653-
@NonNull FrameLayout root, @NonNull ReadableMap initialMapOptions) {
658+
private void tryCommitFragmentTransaction(@NonNull FrameLayout root) {
654659
int viewId = root.getId();
660+
655661
if (isFragmentCreated(viewId)) {
656662
return;
657663
}
658664

659-
ReadableMap latestOptions = mapOptionsCache.get(viewId);
660-
ReadableMap optionsToUse = latestOptions != null ? latestOptions : initialMapOptions;
665+
// If pendingFragments does not contain viewId, view was dropped and we should abort retry loop.
666+
if (!pendingFragments.contains(viewId)) {
667+
return;
668+
}
669+
670+
ReadableMap mapOptions = mapOptionsCache.get(viewId);
671+
if (mapOptions == null) {
672+
return;
673+
}
674+
675+
// If view is not attached to window, retry later.
676+
if (!root.isAttachedToWindow()) {
677+
scheduleFragmentTransaction(root);
678+
return;
679+
}
661680

681+
// Wait for layout to provide a size
662682
int width = root.getWidth();
663683
int height = root.getHeight();
664684
if (width == 0 || height == 0) {
665-
// Wait for layout to provide a size, then retry without the per-frame choreographer loop.
666-
root.post(() -> tryCommitFragmentTransaction(root, optionsToUse));
685+
scheduleFragmentTransaction(root);
667686
return;
668687
}
669688

670-
commitFragmentTransaction(root, optionsToUse);
689+
commitFragmentTransaction(root, mapOptions);
671690
}
672691

673692
private void updateMapOptionValues(int viewId, @NonNull ReadableMap mapOptions) {
@@ -693,26 +712,33 @@ private void updateMapOptionValues(int viewId, @NonNull ReadableMap mapOptions)
693712
}
694713

695714
if (fragment instanceof INavViewFragment && mapOptions.hasKey("navigationNightMode")) {
696-
int nightMode =
715+
int jsValue =
697716
mapOptions.isNull("navigationNightMode") ? 0 : mapOptions.getInt("navigationNightMode");
698717
((INavViewFragment) fragment)
699-
.setNightModeOption(EnumTranslationUtil.getForceNightModeFromJsValue(nightMode));
718+
.setNightModeOption(EnumTranslationUtil.getForceNightModeFromJsValue(jsValue));
700719
}
701720
}
702721

703-
/** Replace your React Native view with a custom fragment */
722+
/**
723+
* Attaches the appropriate Map or Navigation fragment to the given parent view. Uses
724+
* commitNowAllowingStateLoss for immediate attachment. If FragmentManager is busy, retries
725+
* asynchronously by calling scheduleFragmentTransaction.
726+
*/
704727
private void commitFragmentTransaction(
705728
@NonNull FrameLayout view, @NonNull ReadableMap mapOptions) {
706729

707730
FragmentActivity activity = (FragmentActivity) reactContext.getCurrentActivity();
708-
if (activity == null) return;
731+
if (activity == null || activity.isFinishing()) {
732+
return;
733+
}
734+
709735
int viewId = view.getId();
736+
String fragmentTag = String.valueOf(viewId);
710737
Fragment fragment;
711738
IMapViewFragment mapViewFragment;
712739

713740
CustomTypes.MapViewType mapViewType =
714741
EnumTranslationUtil.getMapViewTypeFromJsValue(mapOptions.getInt("mapViewType"));
715-
716742
GoogleMapOptions googleMapOptions = buildGoogleMapOptions(mapOptions);
717743

718744
if (mapViewType == CustomTypes.MapViewType.MAP) {
@@ -723,12 +749,10 @@ private void commitFragmentTransaction(
723749
} else {
724750
NavViewFragment navFragment =
725751
NavViewFragment.newInstance(reactContext, viewId, googleMapOptions);
726-
Integer nightMode = null;
727-
if (mapOptions.hasKey("navigationNightMode")) {
728-
int jsValue =
729-
mapOptions.isNull("navigationNightMode") ? 0 : mapOptions.getInt("navigationNightMode");
730-
nightMode = EnumTranslationUtil.getForceNightModeFromJsValue(jsValue);
731-
navFragment.setNightModeOption(nightMode);
752+
753+
if (mapOptions.hasKey("navigationNightMode") && !mapOptions.isNull("navigationNightMode")) {
754+
int jsValue = mapOptions.getInt("navigationNightMode");
755+
navFragment.setNightModeOption(EnumTranslationUtil.getForceNightModeFromJsValue(jsValue));
732756
}
733757

734758
if (mapOptions.hasKey("navigationStylingOptions")
@@ -743,19 +767,32 @@ private void commitFragmentTransaction(
743767
mapViewFragment = navFragment;
744768
}
745769

746-
fragmentMap.put(viewId, new WeakReference<IMapViewFragment>(mapViewFragment));
770+
// Execute Transaction
771+
try {
772+
activity
773+
.getSupportFragmentManager()
774+
.beginTransaction()
775+
.replace(viewId, fragment, fragmentTag)
776+
.commitNowAllowingStateLoss();
777+
} catch (IllegalStateException e) {
778+
// FragmentManager is busy or Activity state is invalid.
779+
// re-schedule the transaction.
780+
scheduleFragmentTransaction(view);
781+
return;
782+
} catch (Exception e) {
783+
// For other unrecoverable errors, simply abort.
784+
// Most likely the activity is finishing or destroyed.
785+
return;
786+
}
747787

748-
activity
749-
.getSupportFragmentManager()
750-
.beginTransaction()
751-
.replace(viewId, fragment, String.valueOf(viewId))
752-
.commit();
788+
// Fragment created successfully, update state.
789+
pendingFragments.remove(viewId);
790+
mapOptionsCache.remove(viewId);
791+
fragmentMap.put(viewId, new WeakReference<>(mapViewFragment));
753792

754793
// Start per-frame layout loop to keep fragment sized correctly.
755794
startLayoutLoop(view);
756-
757-
// Trigger layout after fragment is added
758-
// Post to ensure fragment transaction is complete
795+
// Trigger layout after fragment transaction is done.
759796
view.post(() -> layoutFragmentInView(view, mapViewFragment));
760797
}
761798

example/.detoxrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ module.exports = {
7171
emulator: {
7272
type: 'android.emulator',
7373
device: {
74-
avdName: 'Pixel_9_Pro_API_35',
74+
avdName: 'Android_Emulator',
7575
},
7676
},
7777
},

example/src/App.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ const HomeScreen = () => {
7777
Navigation SDK Version: {sdkVersion || 'Loading...'}
7878
</Text>
7979
</View>
80-
{/* Spacer */}
8180
<View style={CommonStyles.buttonContainer}>
8281
<ExampleAppButton
8382
title="Navigation"
@@ -96,7 +95,6 @@ const HomeScreen = () => {
9695
onPress={() => isFocused && navigate('Map ID')}
9796
/>
9897
</View>
99-
{/* Spacer */}
10098
<View style={CommonStyles.container} />
10199
<View style={CommonStyles.buttonContainer}>
102100
<ExampleAppButton

example/src/controls/Accordion.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import React, { useState, type ReactNode } from 'react';
18+
import {
19+
View,
20+
Text,
21+
TouchableOpacity,
22+
StyleSheet,
23+
LayoutAnimation,
24+
} from 'react-native';
25+
import { Colors, Spacing, BorderRadius, Typography } from '../styles/theme';
26+
27+
type AccordionProps = {
28+
title: string;
29+
children: ReactNode;
30+
defaultExpanded?: boolean;
31+
};
32+
33+
export const Accordion = ({
34+
title,
35+
children,
36+
defaultExpanded = false,
37+
}: AccordionProps) => {
38+
const [expanded, setExpanded] = useState(defaultExpanded);
39+
40+
const toggleExpanded = () => {
41+
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
42+
setExpanded(!expanded);
43+
};
44+
45+
return (
46+
<View style={styles.container}>
47+
<TouchableOpacity
48+
style={styles.header}
49+
onPress={toggleExpanded}
50+
activeOpacity={0.7}
51+
>
52+
<Text style={styles.title}>{title}</Text>
53+
<Text style={styles.chevron}>{expanded ? '▲' : '▼'}</Text>
54+
</TouchableOpacity>
55+
{expanded && <View style={styles.content}>{children}</View>}
56+
</View>
57+
);
58+
};
59+
60+
const styles = StyleSheet.create({
61+
container: {
62+
marginVertical: Spacing.xs,
63+
marginRight: Spacing.md,
64+
borderRadius: BorderRadius.md,
65+
backgroundColor: Colors.surface,
66+
overflow: 'hidden',
67+
borderWidth: 1,
68+
borderColor: Colors.borderLight,
69+
},
70+
header: {
71+
flexDirection: 'row',
72+
justifyContent: 'space-between',
73+
alignItems: 'center',
74+
paddingVertical: Spacing.md,
75+
paddingHorizontal: Spacing.lg,
76+
backgroundColor: Colors.surfaceVariant,
77+
},
78+
title: {
79+
fontSize: Typography.fontSize.md,
80+
fontWeight: Typography.fontWeight.semibold,
81+
color: Colors.text,
82+
flex: 1,
83+
},
84+
chevron: {
85+
fontSize: Typography.fontSize.sm,
86+
color: Colors.textSecondary,
87+
marginLeft: Spacing.sm,
88+
},
89+
content: {
90+
paddingVertical: Spacing.sm,
91+
paddingHorizontal: Spacing.xs,
92+
},
93+
});
94+
95+
export default Accordion;

0 commit comments

Comments
 (0)