Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,9 @@ yarn run example detox:test:ios-release
```

Android:

> [!NOTE]
> Create emulator named "Android_Emulator" first if you don't have one already:
```bash
yarn run example detox:test:android-release
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import com.google.android.libraries.navigation.StylingOptions;
import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;

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

// Track views with pending fragment creation attempts.
private final HashSet<Integer> pendingFragments = new HashSet<>();

private ReactApplicationContext reactContext;

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

int viewId = view.getId();

pendingFragments.remove(viewId);
mapOptionsCache.remove(viewId);

Choreographer.FrameCallback frameCallback = frameCallbackMap.remove(viewId);
if (frameCallback != null) {
Choreographer.getInstance().removeFrameCallback(frameCallback);
Expand All @@ -196,7 +203,6 @@ public void onDropViewInstance(@NonNull FrameLayout view) {
if (activity == null) return;

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

scheduleFragmentTransaction(view, mapOptions);
if (!pendingFragments.contains(viewId)) {
pendingFragments.add(viewId);
scheduleFragmentTransaction(view);
}
}

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

private void scheduleFragmentTransaction(
@NonNull FrameLayout root, @NonNull ReadableMap mapOptions) {

// Commit the fragment transaction after view is added to the view hierarchy.
root.post(() -> tryCommitFragmentTransaction(root, mapOptions));
private void scheduleFragmentTransaction(@NonNull FrameLayout root) {
root.post(() -> tryCommitFragmentTransaction(root));
}

/** Attempt to create/attach the fragment once the parent view has a real size. */
private void tryCommitFragmentTransaction(
@NonNull FrameLayout root, @NonNull ReadableMap initialMapOptions) {
private void tryCommitFragmentTransaction(@NonNull FrameLayout root) {
int viewId = root.getId();

if (isFragmentCreated(viewId)) {
return;
}

ReadableMap latestOptions = mapOptionsCache.get(viewId);
ReadableMap optionsToUse = latestOptions != null ? latestOptions : initialMapOptions;
// If pendingFragments does not contain viewId, view was dropped and we should abort retry loop.
if (!pendingFragments.contains(viewId)) {
return;
}

ReadableMap mapOptions = mapOptionsCache.get(viewId);
if (mapOptions == null) {
return;
}

// If view is not attached to window, retry later.
if (!root.isAttachedToWindow()) {
scheduleFragmentTransaction(root);
return;
}

// Wait for layout to provide a size
int width = root.getWidth();
int height = root.getHeight();
if (width == 0 || height == 0) {
// Wait for layout to provide a size, then retry without the per-frame choreographer loop.
root.post(() -> tryCommitFragmentTransaction(root, optionsToUse));
scheduleFragmentTransaction(root);
return;
}

commitFragmentTransaction(root, optionsToUse);
commitFragmentTransaction(root, mapOptions);
}

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

if (fragment instanceof INavViewFragment && mapOptions.hasKey("navigationNightMode")) {
int nightMode =
int jsValue =
mapOptions.isNull("navigationNightMode") ? 0 : mapOptions.getInt("navigationNightMode");
((INavViewFragment) fragment)
.setNightModeOption(EnumTranslationUtil.getForceNightModeFromJsValue(nightMode));
.setNightModeOption(EnumTranslationUtil.getForceNightModeFromJsValue(jsValue));
}
}

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

FragmentActivity activity = (FragmentActivity) reactContext.getCurrentActivity();
if (activity == null) return;
if (activity == null || activity.isFinishing()) {
return;
}

int viewId = view.getId();
String fragmentTag = String.valueOf(viewId);
Fragment fragment;
IMapViewFragment mapViewFragment;

CustomTypes.MapViewType mapViewType =
EnumTranslationUtil.getMapViewTypeFromJsValue(mapOptions.getInt("mapViewType"));

GoogleMapOptions googleMapOptions = buildGoogleMapOptions(mapOptions);

if (mapViewType == CustomTypes.MapViewType.MAP) {
Expand All @@ -723,12 +749,10 @@ private void commitFragmentTransaction(
} else {
NavViewFragment navFragment =
NavViewFragment.newInstance(reactContext, viewId, googleMapOptions);
Integer nightMode = null;
if (mapOptions.hasKey("navigationNightMode")) {
int jsValue =
mapOptions.isNull("navigationNightMode") ? 0 : mapOptions.getInt("navigationNightMode");
nightMode = EnumTranslationUtil.getForceNightModeFromJsValue(jsValue);
navFragment.setNightModeOption(nightMode);

if (mapOptions.hasKey("navigationNightMode") && !mapOptions.isNull("navigationNightMode")) {
int jsValue = mapOptions.getInt("navigationNightMode");
navFragment.setNightModeOption(EnumTranslationUtil.getForceNightModeFromJsValue(jsValue));
}

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

fragmentMap.put(viewId, new WeakReference<IMapViewFragment>(mapViewFragment));
// Execute Transaction
try {
activity
.getSupportFragmentManager()
.beginTransaction()
.replace(viewId, fragment, fragmentTag)
.commitNowAllowingStateLoss();
} catch (IllegalStateException e) {
// FragmentManager is busy or Activity state is invalid.
// re-schedule the transaction.
scheduleFragmentTransaction(view);
return;
} catch (Exception e) {
// For other unrecoverable errors, simply abort.
// Most likely the activity is finishing or destroyed.
return;
}

activity
.getSupportFragmentManager()
.beginTransaction()
.replace(viewId, fragment, String.valueOf(viewId))
.commit();
// Fragment created successfully, update state.
pendingFragments.remove(viewId);
mapOptionsCache.remove(viewId);
fragmentMap.put(viewId, new WeakReference<>(mapViewFragment));

// Start per-frame layout loop to keep fragment sized correctly.
startLayoutLoop(view);

// Trigger layout after fragment is added
// Post to ensure fragment transaction is complete
// Trigger layout after fragment transaction is done.
view.post(() -> layoutFragmentInView(view, mapViewFragment));
}

Expand Down
2 changes: 1 addition & 1 deletion example/.detoxrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ module.exports = {
emulator: {
type: 'android.emulator',
device: {
avdName: 'Pixel_9_Pro_API_35',
avdName: 'Android_Emulator',
},
},
},
Expand Down
2 changes: 0 additions & 2 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ const HomeScreen = () => {
Navigation SDK Version: {sdkVersion || 'Loading...'}
</Text>
</View>
{/* Spacer */}
<View style={CommonStyles.buttonContainer}>
<ExampleAppButton
title="Navigation"
Expand All @@ -96,7 +95,6 @@ const HomeScreen = () => {
onPress={() => isFocused && navigate('Map ID')}
/>
</View>
{/* Spacer */}
<View style={CommonStyles.container} />
<View style={CommonStyles.buttonContainer}>
<ExampleAppButton
Expand Down
95 changes: 95 additions & 0 deletions example/src/controls/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import React, { useState, type ReactNode } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
LayoutAnimation,
} from 'react-native';
import { Colors, Spacing, BorderRadius, Typography } from '../styles/theme';

type AccordionProps = {
title: string;
children: ReactNode;
defaultExpanded?: boolean;
};

export const Accordion = ({
title,
children,
defaultExpanded = false,
}: AccordionProps) => {
const [expanded, setExpanded] = useState(defaultExpanded);

const toggleExpanded = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpanded(!expanded);
};

return (
<View style={styles.container}>
<TouchableOpacity
style={styles.header}
onPress={toggleExpanded}
activeOpacity={0.7}
>
<Text style={styles.title}>{title}</Text>
<Text style={styles.chevron}>{expanded ? '▲' : '▼'}</Text>
</TouchableOpacity>
{expanded && <View style={styles.content}>{children}</View>}
</View>
);
};

const styles = StyleSheet.create({
container: {
marginVertical: Spacing.xs,
marginRight: Spacing.md,
borderRadius: BorderRadius.md,
backgroundColor: Colors.surface,
overflow: 'hidden',
borderWidth: 1,
borderColor: Colors.borderLight,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.lg,
backgroundColor: Colors.surfaceVariant,
},
title: {
fontSize: Typography.fontSize.md,
fontWeight: Typography.fontWeight.semibold,
color: Colors.text,
flex: 1,
},
chevron: {
fontSize: Typography.fontSize.sm,
color: Colors.textSecondary,
marginLeft: Spacing.sm,
},
content: {
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.xs,
},
});

export default Accordion;
Loading
Loading