From 5f503c5c25f00816df911047908755672974eb2d Mon Sep 17 00:00:00 2001
From: savsch <119003089+savsch@users.noreply.github.com>
Date: Mon, 10 Mar 2025 14:03:07 +0530
Subject: [PATCH] [android] Add modal search
Signed-off-by: savsch <119003089+savsch@users.noreply.github.com>
---
android/app/src/main/AndroidManifest.xml | 8 -
.../java/app/organicmaps/MwmActivity.java | 49 ++-
.../java/app/organicmaps/intent/Factory.java | 3 +-
.../organicmaps/search/SearchActivity.java | 49 ---
.../organicmaps/search/SearchFragment.java | 67 ++--
.../modalsearch/ModalSearchController.java | 305 ++++++++++++++++++
.../widget/modalsearch/ModalSearchUtils.java | 41 +++
.../modalsearch/ModalSearchViewModel.java | 64 ++++
.../SearchBottomSheetBehavior.java | 113 +++++++
.../res/drawable/bg_modal_search_header.xml | 5 +
.../app/src/main/res/layout/activity_map.xml | 5 +
.../src/main/res/layout/fragment_search.xml | 3 +-
.../modal_search_container_fragment.xml | 44 +++
.../app/src/main/res/values-land/dimens.xml | 3 +
.../app/src/main/res/values-land/fraction.xml | 5 +
.../main/res/values-w1020dp-land/fraction.xml | 5 +
android/app/src/main/res/values/dimens.xml | 3 +
.../src/main/res/values/donottranslate.xml | 1 +
android/app/src/main/res/values/fraction.xml | 5 +
19 files changed, 691 insertions(+), 87 deletions(-)
delete mode 100644 android/app/src/main/java/app/organicmaps/search/SearchActivity.java
create mode 100644 android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchController.java
create mode 100644 android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchUtils.java
create mode 100644 android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchViewModel.java
create mode 100644 android/app/src/main/java/app/organicmaps/widget/modalsearch/SearchBottomSheetBehavior.java
create mode 100644 android/app/src/main/res/drawable/bg_modal_search_header.xml
create mode 100644 android/app/src/main/res/layout/modal_search_container_fragment.xml
create mode 100644 android/app/src/main/res/values-land/fraction.xml
create mode 100644 android/app/src/main/res/values-w1020dp-land/fraction.xml
create mode 100644 android/app/src/main/res/values/fraction.xml
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 86bdadc494..3a35f8b018 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -380,14 +380,6 @@
android:parentActivityName="app.organicmaps.MwmActivity"
android:windowSoftInputMode="adjustResize" />
-
-
{
this.updateBottomWidgetsOffset();
if (visible)
+ {
mPlacePageViewModel.setPlacePageDistanceToTop(menuFrame.getTop());
+ mSearchViewModel.setModalSearchDistanceToTop(menuFrame.getTop());
+ }
});
if (mIsTabletLayout)
@@ -1271,6 +1286,7 @@ public class MwmActivity extends BaseMwmFragmentActivity
{
// This will open the place page
mPlacePageViewModel.setMapObject((MapObject) data);
+ mSearchViewModel.setModalSearchSuspended(true);
}
// Called from JNI.
@@ -1279,6 +1295,7 @@ public class MwmActivity extends BaseMwmFragmentActivity
public void onPlacePageDeactivated()
{
closePlacePage();
+ mSearchViewModel.setModalSearchSuspended(false);
}
// Called from JNI.
@@ -2415,6 +2432,26 @@ public class MwmActivity extends BaseMwmFragmentActivity
mAlertDialog.show();
}
+ public void handleSearchLink(String query, String locale, boolean isSearchOnMap)
+ {
+ final Bundle args = new Bundle();
+ args.putString(SearchFragment.ARG_QUERY, query);
+ args.putString(SearchFragment.ARG_LOCALE, locale);
+ args.putBoolean(SearchFragment.ARG_SEARCH_ON_MAP, isSearchOnMap);
+ if (mIsTabletLayout)
+ {
+ closeSearchToolbar(true, true);
+ replaceFragment(SearchFragment.class, args, null);
+ }
+ else
+ {
+ final ModalSearchController fragment = (ModalSearchController) getSupportFragmentManager().findFragmentById(R.id.modal_search_container_fragment);
+ if (fragment == null)
+ throw new IllegalStateException("Must be called with a valid R.id.modal_search_container_fragment fragment in the layout");
+ fragment.restartSearch(args);
+ }
+ }
+
public void onShareLocationOptionSelected()
{
closeFloatingPanels();
diff --git a/android/app/src/main/java/app/organicmaps/intent/Factory.java b/android/app/src/main/java/app/organicmaps/intent/Factory.java
index 0fd3c867e9..eaad5d0b64 100644
--- a/android/app/src/main/java/app/organicmaps/intent/Factory.java
+++ b/android/app/src/main/java/app/organicmaps/intent/Factory.java
@@ -20,7 +20,6 @@ import app.organicmaps.bookmarks.data.FeatureId;
import app.organicmaps.bookmarks.data.MapObject;
import app.organicmaps.editor.OsmLoginActivity;
import app.organicmaps.routing.RoutingController;
-import app.organicmaps.search.SearchActivity;
import app.organicmaps.sdk.search.SearchEngine;
import app.organicmaps.util.StorageUtils;
import app.organicmaps.util.concurrency.ThreadPool;
@@ -114,7 +113,7 @@ public class Factory
if (!request.mIsSearchOnMap)
Framework.nativeSetSearchViewport(latlon[0], latlon[1], SEARCH_IN_VIEWPORT_ZOOM);
}
- SearchActivity.start(target, request.mQuery, request.mLocale, request.mIsSearchOnMap);
+ target.handleSearchLink(request.mQuery, request.mLocale, request.mIsSearchOnMap);
return true;
}
case RequestType.CROSSHAIR:
diff --git a/android/app/src/main/java/app/organicmaps/search/SearchActivity.java b/android/app/src/main/java/app/organicmaps/search/SearchActivity.java
deleted file mode 100644
index abcb5cd1bd..0000000000
--- a/android/app/src/main/java/app/organicmaps/search/SearchActivity.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package app.organicmaps.search;
-
-import android.app.Activity;
-import android.content.Intent;
-import android.os.Bundle;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StyleRes;
-import androidx.fragment.app.Fragment;
-import app.organicmaps.base.BaseMwmFragmentActivity;
-import app.organicmaps.util.ThemeUtils;
-
-public class SearchActivity extends BaseMwmFragmentActivity
-{
- public static final String EXTRA_QUERY = "search_query";
- public static final String EXTRA_LOCALE = "locale";
- public static final String EXTRA_SEARCH_ON_MAP = "search_on_map";
-
- public static void start(@NonNull Activity activity, @Nullable String query)
- {
- start(activity, query, null /* locale */, false /* isSearchOnMap */);
- }
-
- public static void start(@NonNull Activity activity, @Nullable String query, @Nullable String locale,
- boolean isSearchOnMap)
- {
- final Intent i = new Intent(activity, SearchActivity.class);
- Bundle args = new Bundle();
- args.putString(EXTRA_QUERY, query);
- args.putString(EXTRA_LOCALE, locale);
- args.putBoolean(EXTRA_SEARCH_ON_MAP, isSearchOnMap);
- i.putExtras(args);
- activity.startActivity(i);
- }
-
- @Override
- @StyleRes
- public int getThemeResourceId(@NonNull String theme)
- {
- return ThemeUtils.getCardBgThemeResourceId(getApplicationContext(), theme);
- }
-
- @Override
- protected Class extends Fragment> getFragmentClass()
- {
- return SearchFragment.class;
- }
-}
diff --git a/android/app/src/main/java/app/organicmaps/search/SearchFragment.java b/android/app/src/main/java/app/organicmaps/search/SearchFragment.java
index 39db986096..789b0f7b03 100644
--- a/android/app/src/main/java/app/organicmaps/search/SearchFragment.java
+++ b/android/app/src/main/java/app/organicmaps/search/SearchFragment.java
@@ -20,6 +20,7 @@ import androidx.appcompat.widget.Toolbar;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
+import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager.widget.ViewPager;
@@ -46,6 +47,8 @@ import app.organicmaps.util.Utils;
import app.organicmaps.util.WindowInsetUtils;
import app.organicmaps.widget.PlaceholderView;
import app.organicmaps.widget.SearchToolbarController;
+import app.organicmaps.widget.modalsearch.ModalSearchViewModel;
+
import com.google.android.material.appbar.AppBarLayout;
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton;
import com.google.android.material.tabs.TabLayout;
@@ -58,6 +61,11 @@ public class SearchFragment extends BaseMwmFragment
implements SearchListener,
CategoriesAdapter.CategoriesUiListener
{
+ public static final String ARG_QUERY = "search_query";
+ public static final String ARG_LOCALE = "locale";
+ public static final String ARG_SEARCH_ON_MAP = "search_on_map";
+
+ private ModalSearchViewModel mViewModel;
private long mLastQueryTimestamp;
@NonNull
private final List mHiddenCommands = new ArrayList<>();
@@ -78,9 +86,18 @@ public class SearchFragment extends BaseMwmFragment
private class ToolbarController extends SearchToolbarController
{
- public ToolbarController(View root)
+ public ToolbarController(View root, boolean isSearchModal)
{
super(root, SearchFragment.this.requireActivity());
+ if (isSearchModal) // remove toolbar padding in case the search is modal
+ ViewCompat.setOnApplyWindowInsetsListener(getToolbar(), new WindowInsetUtils.PaddingInsetsListener(false, false, false, false));
+ }
+
+ @Override
+ public void onClick(View v)
+ {
+ super.onClick(v);
+ mViewModel.setModalSearchCollapsed(false);
}
@Override
@@ -92,6 +109,7 @@ public class SearchFragment extends BaseMwmFragment
if (TextUtils.isEmpty(query))
{
mSearchAdapter.clear();
+ mViewModel.setIsQueryEmpty(true);
stopSearch();
return;
}
@@ -104,6 +122,7 @@ public class SearchFragment extends BaseMwmFragment
return;
}
+ mViewModel.setIsQueryEmpty(false);
runSearch();
}
@@ -170,6 +189,7 @@ public class SearchFragment extends BaseMwmFragment
private final LastPosition mLastPosition = new LastPosition();
private boolean mSearchRunning;
+ private boolean mIsModal;
private String mInitialQuery;
@Nullable
private String mInitialLocale;
@@ -230,12 +250,12 @@ public class SearchFragment extends BaseMwmFragment
final boolean hasQuery = mToolbarController.hasQuery();
Toolbar toolbar = mToolbarController.getToolbar();
AppBarLayout.LayoutParams lp = (AppBarLayout.LayoutParams) toolbar.getLayoutParams();
- lp.setScrollFlags(hasQuery ? AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
- | AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL : 0);
+ lp.setScrollFlags(!mIsModal && hasQuery ? AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
+ | AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL : 0);
toolbar.setLayoutParams(lp);
UiUtils.showIf(hasQuery, mResultsFrame);
- UiUtils.showIf(hasQuery, mShowOnMapFab);
+ UiUtils.showIf(!mIsModal && hasQuery, mShowOnMapFab);
if (hasQuery)
hideDownloadSuggest();
else if (doShowDownloadSuggest())
@@ -256,6 +276,7 @@ public class SearchFragment extends BaseMwmFragment
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
{
+ mViewModel = new ViewModelProvider(requireActivity()).get(ModalSearchViewModel.class);
return inflater.inflate(R.layout.fragment_search, container, false);
}
@@ -271,7 +292,10 @@ public class SearchFragment extends BaseMwmFragment
View mTabFrame = root.findViewById(R.id.tab_frame);
ViewPager pager = mTabFrame.findViewById(R.id.pages);
- mToolbarController = new ToolbarController(view);
+ final Fragment modalSearchFragment = requireActivity().getSupportFragmentManager().findFragmentById(R.id.modal_search_container_fragment);
+ mIsModal = modalSearchFragment != null && modalSearchFragment.isAdded();
+
+ mToolbarController = new ToolbarController(view, mIsModal);
TabLayout tabLayout = root.findViewById(R.id.tabs);
if (Config.isSearchHistoryEnabled())
@@ -320,6 +344,11 @@ public class SearchFragment extends BaseMwmFragment
if (mInitialSearchOnMap)
showAllResultsOnMap();
+
+ view.findViewById(R.id.query).setOnFocusChangeListener((v, hasFocus) -> {
+ if (hasFocus)
+ mViewModel.setModalSearchCollapsed(false);
+ });
}
@Override
@@ -374,9 +403,9 @@ public class SearchFragment extends BaseMwmFragment
if (arguments == null)
return;
- mInitialQuery = arguments.getString(SearchActivity.EXTRA_QUERY);
- mInitialLocale = arguments.getString(SearchActivity.EXTRA_LOCALE);
- mInitialSearchOnMap = arguments.getBoolean(SearchActivity.EXTRA_SEARCH_ON_MAP);
+ mInitialQuery = arguments.getString(ARG_QUERY);
+ mInitialLocale = arguments.getString(ARG_LOCALE);
+ mInitialSearchOnMap = arguments.getBoolean(ARG_SEARCH_ON_MAP);
}
private boolean tryRecognizeHiddenCommand(@NonNull String query)
@@ -421,16 +450,12 @@ public class SearchFragment extends BaseMwmFragment
final MapObject point = MapObject.createMapObject(FeatureId.EMPTY, MapObject.SEARCH,
title, subtitle, result.lat, result.lon);
RoutingController.get().onPoiSelected(point);
+ mViewModel.setModalSearchActive(false);
}
else
- {
SearchEngine.INSTANCE.showResult(resultIndex);
- }
mToolbarController.deactivate();
-
- if (requireActivity() instanceof SearchActivity)
- Utils.navigateToParent(requireActivity());
}
void showAllResultsOnMap()
@@ -486,7 +511,7 @@ public class SearchFragment extends BaseMwmFragment
SearchEngine.INSTANCE.cancel();
mLastQueryTimestamp = System.nanoTime();
- if (isTabletSearch())
+ if (mIsModal || isTabletSearch())
{
SearchEngine.INSTANCE.searchInteractive(requireContext(), getQuery(), isCategory(),
mLastQueryTimestamp, true /* isMapAndTable */);
@@ -548,19 +573,19 @@ public class SearchFragment extends BaseMwmFragment
return true;
}
- boolean isSearchActivity = requireActivity() instanceof SearchActivity;
+ final boolean isModalSearchActive = Boolean.TRUE.equals(mViewModel.getModalSearchActive().getValue());
mToolbarController.deactivate();
if (RoutingController.get().isWaitingPoiPick())
{
RoutingController.get().onPoiSelected(null);
- if (isSearchActivity)
- closeSearch();
- return !isSearchActivity;
+ if (isModalSearchActive)
+ mViewModel.setModalSearchActive(false);
+ return !isModalSearchActive;
}
- if (isSearchActivity)
- closeSearch();
- return isSearchActivity;
+ if (isModalSearchActive)
+ mViewModel.setModalSearchActive(false);
+ return isModalSearchActive;
}
private void closeSearch()
diff --git a/android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchController.java b/android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchController.java
new file mode 100644
index 0000000000..7c73d279f5
--- /dev/null
+++ b/android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchController.java
@@ -0,0 +1,305 @@
+package app.organicmaps.widget.modalsearch;
+
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.coordinatorlayout.widget.CoordinatorLayout;
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+import androidx.core.widget.NestedScrollView;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.fragment.app.FragmentManager;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.google.android.material.bottomsheet.BottomSheetBehavior;
+
+import app.organicmaps.Framework;
+import app.organicmaps.R;
+import app.organicmaps.sdk.search.SearchEngine;
+import app.organicmaps.search.SearchFragment;
+import app.organicmaps.util.InputUtils;
+
+public class ModalSearchController extends Fragment
+{
+ private static final String SEARCH_FRAGMENT_TAG = SearchFragment.class.getSimpleName();
+ private SearchBottomSheetBehavior mSearchBehavior;
+ private NestedScrollView mModalSearch;
+ private ViewGroup mCoordinator;
+ private int mViewportMinHeight;
+ private ModalSearchViewModel mViewModel;
+ private final Observer mModalSearchSuspendedObserver = suspended -> {
+ if (Boolean.FALSE.equals(mViewModel.getModalSearchActive().getValue()))
+ return;
+ if (suspended)
+ {
+ mModalSearch.setVisibility(View.GONE);
+ InputUtils.hideKeyboard(mModalSearch);
+ }
+ else
+ mModalSearch.setVisibility(View.VISIBLE);
+ };
+ private ViewGroup mModalSearchFragmentContainer;
+ private WindowInsetsCompat mCurrentWindowInsets;
+ private int mDistanceToTop;
+ private final BottomSheetBehavior.BottomSheetCallback mDefaultBottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback()
+ {
+ @Override
+ public void onStateChanged(@NonNull View bottomSheet, int newState)
+ {
+ if (ModalSearchUtils.isSettlingState(newState) || ModalSearchUtils.isDraggingState(newState))
+ return;
+
+ ModalSearchUtils.updateMapViewport(mCoordinator, mDistanceToTop, mViewportMinHeight);
+
+ if (ModalSearchUtils.isHiddenState(newState))
+ onHiddenInternal();
+ }
+
+ @Override
+ public void onSlide(@NonNull View bottomSheet, float slideOffset)
+ {
+ mDistanceToTop = bottomSheet.getTop();
+ mViewModel.setModalSearchDistanceToTop(mDistanceToTop);
+ }
+ };
+ private int mDisplayHeight;
+ private CoordinatorLayout mSearchFragmentCoordinator;
+ private final FragmentManager.FragmentLifecycleCallbacks mFragmentLifecycleCallbacks = new FragmentManager.FragmentLifecycleCallbacks()
+ {
+ @Override
+ public void onFragmentStarted(@NonNull FragmentManager fm, @NonNull Fragment f)
+ {
+ mSearchFragmentCoordinator = mModalSearchFragmentContainer.findViewById(R.id.coordinator);
+ super.onFragmentStarted(fm, f);
+ }
+ };
+ private FrameLayout mDragIndicator;
+ private final Observer mModalSearchDistanceToTopObserver = new Observer<>()
+ {
+ private int mDragHandleHeight;
+
+ @Override
+ public void onChanged(Integer distanceToTop)
+ {
+ if (mDragHandleHeight == 0)
+ {
+ mDragHandleHeight = mDragIndicator.getMeasuredHeight();
+ }
+ int topInset = 0, bottomInset = 0;
+ if (mCurrentWindowInsets != null)
+ {
+ Insets insets = mCurrentWindowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
+ topInset = insets.top;
+ bottomInset = insets.bottom;
+ }
+ final int topInsetOverlap = Math.max(0, topInset - distanceToTop);
+ mDragIndicator.setPadding(0, topInsetOverlap, 0, 0);
+ if (mSearchFragmentCoordinator != null)
+ {
+ ViewGroup.LayoutParams params = mSearchFragmentCoordinator.getLayoutParams();
+ params.height = mDisplayHeight - distanceToTop - topInsetOverlap - mDragHandleHeight - bottomInset;
+ mSearchFragmentCoordinator.setLayoutParams(params);
+ }
+ }
+ };
+ private final Observer mModalSearchActiveObserver = active -> {
+ if (active)
+ startSearch(null);
+ else
+ closeSearch();
+ };
+ private final Observer mModalSearchCollapsedObserver = collapsed -> {
+ if (collapsed)
+ {
+ setCollapsible(true);
+ mSearchBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
+ }
+ else
+ {
+ if (mSearchBehavior.getState() == BottomSheetBehavior.STATE_COLLAPSED)
+ mSearchBehavior.setState(BottomSheetBehavior.STATE_HALF_EXPANDED);
+ if (Boolean.TRUE.equals(mViewModel.getIsQueryEmpty().getValue()))
+ setCollapsible(false);
+ }
+ };
+ private final Observer mIsQueryEmptyObserver = isQueryEmpty -> setCollapsible(!isQueryEmpty);
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState)
+ {
+ View rootView = inflater.inflate(R.layout.modal_search_container_fragment, container, false);
+ rootView.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
+ mDisplayHeight = rootView.getHeight();
+ if (mSearchBehavior != null)
+ {
+ Rect r = new Rect();
+ rootView.getWindowVisibleDisplayFrame(r);
+ final int availableHeight = (r.bottom - r.top);
+ mSearchBehavior.updateUnavailableScreenRatio((float) (mDisplayHeight - availableHeight) / mDisplayHeight);
+ }
+ });
+ return rootView;
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
+ {
+ super.onViewCreated(view, savedInstanceState);
+ final FragmentActivity activity = requireActivity();
+
+ final Resources res = activity.getResources();
+ mViewportMinHeight = res.getDimensionPixelSize(R.dimen.viewport_min_height);
+
+ mCoordinator = activity.findViewById(R.id.coordinator);
+ mModalSearch = activity.findViewById(R.id.modal_search);
+ mDragIndicator = mModalSearch.findViewById(R.id.drag_indicator);
+ mModalSearch.setNestedScrollingEnabled(false);
+ mModalSearchFragmentContainer = activity.findViewById(R.id.modal_search_fragment);
+ mSearchBehavior = SearchBottomSheetBehavior.from(
+ mModalSearch,
+ getLifecycle(),
+ getResources().getFraction(R.fraction.modal_search_half_expanded_ratio, 1, 1)
+ );
+
+ mSearchBehavior.setHideable(true);
+ mSearchBehavior.setPeekHeight(300);
+ mSearchBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
+ mSearchBehavior.setFitToContents(false);
+ mSearchBehavior.setSkipCollapsed(true);
+
+ mViewModel = new ViewModelProvider(requireActivity()).get(ModalSearchViewModel.class);
+
+ ViewCompat.setOnApplyWindowInsetsListener(mModalSearch, (v, windowInsets) -> {
+ mCurrentWindowInsets = windowInsets;
+ return windowInsets;
+ });
+
+ ViewCompat.requestApplyInsets(mModalSearch);
+ }
+
+
+ @Override
+ public void onConfigurationChanged(@NonNull Configuration newConfig)
+ {
+ super.onConfigurationChanged(newConfig);
+ }
+
+ private void onHiddenInternal()
+ {
+ Framework.nativeDeactivatePopup();
+ ModalSearchUtils.updateMapViewport(mCoordinator, mDistanceToTop, mViewportMinHeight);
+ removeModalSearchFragments();
+ }
+
+ private void startSearch(Bundle searchArguments)
+ {
+ createModalSearchFragments(searchArguments);
+ mDragIndicator.setVisibility(View.VISIBLE);
+ mModalSearch.setVisibility(View.VISIBLE);
+ mSearchBehavior.setState(BottomSheetBehavior.STATE_HALF_EXPANDED);
+ }
+
+ private void closeSearch()
+ {
+ SearchEngine.INSTANCE.cancel();
+ mSearchBehavior.setHideable(true);
+ mSearchBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
+ removeModalSearchFragments();
+ mDragIndicator.setVisibility(View.GONE);
+ }
+
+ private void setCollapsible(boolean collapsible)
+ {
+ if (collapsible)
+ {
+ mSearchBehavior.setSkipCollapsed(false);
+ mSearchBehavior.setPeekHeight(calculateCollapsedHeight());
+ }
+ else
+ mSearchBehavior.setSkipCollapsed(true);
+ }
+
+ private int calculateCollapsedHeight()
+ {
+ try
+ {
+ return mDragIndicator.getMeasuredHeight() +
+ mModalSearch.findViewById(R.id.app_bar).getMeasuredHeight(); // TODO(savsch) get feedback on whether to change this height
+ } catch (NullPointerException npe)
+ {
+ return 0;
+ }
+ }
+
+ private void removeModalSearchFragments()
+ {
+ final FragmentManager fm = getChildFragmentManager();
+ final Fragment modalSearchFragment = fm.findFragmentByTag(SEARCH_FRAGMENT_TAG);
+
+ if (modalSearchFragment != null)
+ {
+ fm.beginTransaction()
+ .setReorderingAllowed(true)
+ .remove(modalSearchFragment)
+ .commit();
+ }
+ }
+
+ private void createModalSearchFragments(Bundle searchArguments)
+ {
+ final FragmentManager fm = getChildFragmentManager();
+ if (fm.findFragmentByTag(SEARCH_FRAGMENT_TAG) == null)
+ {
+ fm.beginTransaction()
+ .setReorderingAllowed(true)
+ .add(R.id.modal_search_fragment, SearchFragment.class, searchArguments, SEARCH_FRAGMENT_TAG)
+ .commit();
+ }
+ }
+
+ public void restartSearch(Bundle searchArguments)
+ {
+ closeSearch();
+ getChildFragmentManager().executePendingTransactions();
+ startSearch(searchArguments);
+ }
+
+ @Override
+ public void onStart()
+ {
+ super.onStart();
+ mSearchBehavior.addBottomSheetCallback(mDefaultBottomSheetCallback);
+ getChildFragmentManager().registerFragmentLifecycleCallbacks(mFragmentLifecycleCallbacks, false);
+ FragmentActivity activity = requireActivity();
+ mViewModel.getModalSearchDistanceToTop().observe(activity, mModalSearchDistanceToTopObserver);
+ mViewModel.getModalSearchActive().observe(activity, mModalSearchActiveObserver);
+ mViewModel.getModalSearchCollapsed().observe(activity, mModalSearchCollapsedObserver);
+ mViewModel.getModalSearchSuspended().observe(activity, mModalSearchSuspendedObserver);
+ mViewModel.getIsQueryEmpty().observe(activity, mIsQueryEmptyObserver);
+ }
+
+ @Override
+ public void onStop()
+ {
+ super.onStop();
+ mSearchBehavior.removeBottomSheetCallback(mDefaultBottomSheetCallback);
+ getChildFragmentManager().unregisterFragmentLifecycleCallbacks(mFragmentLifecycleCallbacks);
+ mViewModel.getModalSearchDistanceToTop().removeObserver(mModalSearchDistanceToTopObserver);
+ mViewModel.getModalSearchActive().removeObserver(mModalSearchActiveObserver);
+ mViewModel.getModalSearchCollapsed().removeObserver(mModalSearchCollapsedObserver);
+ mViewModel.getModalSearchSuspended().removeObserver(mModalSearchSuspendedObserver);
+ mViewModel.getIsQueryEmpty().removeObserver(mIsQueryEmptyObserver);
+ }
+}
diff --git a/android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchUtils.java b/android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchUtils.java
new file mode 100644
index 0000000000..0436f807bd
--- /dev/null
+++ b/android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchUtils.java
@@ -0,0 +1,41 @@
+package app.organicmaps.widget.modalsearch;
+
+import android.view.View;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.material.bottomsheet.BottomSheetBehavior;
+
+import app.organicmaps.Framework;
+import app.organicmaps.display.DisplayManager;
+
+public class ModalSearchUtils
+{
+
+ static void updateMapViewport(@NonNull View parent, int newSearchDistanceToTop, int viewportMinHeight)
+ {
+ parent.post(() -> {
+ // Because of the post(), this lambda is called after the car.SurfaceRenderer.onStableAreaChanged() and breaks the visibleRect configuration
+ if (DisplayManager.from(parent.getContext()).isCarDisplayUsed())
+ return;
+ final int screenWidth = parent.getWidth();
+ if (newSearchDistanceToTop >= viewportMinHeight)
+ Framework.nativeSetVisibleRect(0, 0, screenWidth, newSearchDistanceToTop);
+ });
+ }
+
+ static boolean isSettlingState(@BottomSheetBehavior.State int state)
+ {
+ return state == BottomSheetBehavior.STATE_SETTLING;
+ }
+
+ static boolean isDraggingState(@BottomSheetBehavior.State int state)
+ {
+ return state == BottomSheetBehavior.STATE_DRAGGING;
+ }
+
+ static boolean isHiddenState(@BottomSheetBehavior.State int state)
+ {
+ return state == BottomSheetBehavior.STATE_HIDDEN;
+ }
+}
diff --git a/android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchViewModel.java b/android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchViewModel.java
new file mode 100644
index 0000000000..f04870dbf8
--- /dev/null
+++ b/android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchViewModel.java
@@ -0,0 +1,64 @@
+package app.organicmaps.widget.modalsearch;
+
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+
+public class ModalSearchViewModel extends ViewModel
+{
+ private final MutableLiveData mModalSearchDistanceToTop = new MutableLiveData<>();
+ private final MutableLiveData mModalSearchActive = new MutableLiveData<>();
+ private final MutableLiveData mModalSearchSuspended = new MutableLiveData<>();
+ private final MutableLiveData mModalSearchCollapsed = new MutableLiveData<>();
+ private final MutableLiveData mIsQueryEmpty = new MutableLiveData<>(true);
+
+
+ public MutableLiveData getModalSearchDistanceToTop()
+ {
+ return mModalSearchDistanceToTop;
+ }
+
+ public void setModalSearchDistanceToTop(int top)
+ {
+ mModalSearchDistanceToTop.setValue(top);
+ }
+
+ public MutableLiveData getModalSearchActive()
+ {
+ return mModalSearchActive;
+ }
+
+ public void setModalSearchActive(Boolean active)
+ {
+ mModalSearchActive.setValue(active);
+ }
+
+ public MutableLiveData getModalSearchSuspended()
+ {
+ return mModalSearchSuspended;
+ }
+
+ public void setModalSearchSuspended(Boolean suspended)
+ {
+ mModalSearchSuspended.setValue(suspended);
+ }
+
+ public MutableLiveData getModalSearchCollapsed()
+ {
+ return mModalSearchCollapsed;
+ }
+
+ public void setModalSearchCollapsed(Boolean collapsed)
+ {
+ mModalSearchCollapsed.setValue(collapsed);
+ }
+
+ public MutableLiveData getIsQueryEmpty()
+ {
+ return mIsQueryEmpty;
+ }
+
+ public void setIsQueryEmpty(Boolean isQueryEmpty)
+ {
+ mIsQueryEmpty.setValue(isQueryEmpty);
+ }
+}
diff --git a/android/app/src/main/java/app/organicmaps/widget/modalsearch/SearchBottomSheetBehavior.java b/android/app/src/main/java/app/organicmaps/widget/modalsearch/SearchBottomSheetBehavior.java
new file mode 100644
index 0000000000..8a58ac3e75
--- /dev/null
+++ b/android/app/src/main/java/app/organicmaps/widget/modalsearch/SearchBottomSheetBehavior.java
@@ -0,0 +1,113 @@
+package app.organicmaps.widget.modalsearch;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleEventObserver;
+
+import com.google.android.material.bottomsheet.BottomSheetBehavior;
+
+public class SearchBottomSheetBehavior extends BottomSheetBehavior
+{
+ private float mHalfExpandedBaseRatio = 0.5f;
+ private float mHalfExpandedRatio = mHalfExpandedBaseRatio;
+ private final SheetCollapseHelper sheetSlideHelper = new SheetCollapseHelper()
+ {
+ private static final float COLLAPSE_VELOCITY_THRESHOLD = 0.2f; // offset units per second
+ private static final long VELOCITY_TRACKING_TIMEOUT_MS = 150;
+ private float mLastSlideOffset = 0f;
+ private long mLastSlideTimeMillis = 0;
+
+ public void onSlide(float slideOffset)
+ {
+ if (getSkipCollapsed() || getState() != STATE_DRAGGING || slideOffset >= getHalfExpandedRatio())
+ return;
+ long currentTimeMillis = System.currentTimeMillis();
+ if (currentTimeMillis - mLastSlideTimeMillis < VELOCITY_TRACKING_TIMEOUT_MS)
+ {
+ // Calculate velocity (negative means downward movement)
+ float timeDelta = (currentTimeMillis - mLastSlideTimeMillis) / 1000f; // convert to seconds
+ float offsetDelta = slideOffset - mLastSlideOffset;
+ float velocity = offsetDelta / timeDelta;
+ if (velocity < -COLLAPSE_VELOCITY_THRESHOLD)
+ {
+ // detected a fast downward swipe from half-expanded state, so collapse
+ setState(BottomSheetBehavior.STATE_COLLAPSED);
+ }
+ }
+ mLastSlideTimeMillis = currentTimeMillis;
+ mLastSlideOffset = slideOffset;
+ }
+ };
+ private final BottomSheetCallback easierCollapseCallback = new BottomSheetCallback()
+ {
+ @Override
+ public void onStateChanged(@NonNull View bottomSheet, int newState)
+ {
+ if (newState == STATE_HALF_EXPANDED && mHalfExpandedRatio != getHalfExpandedRatio())
+ setState(STATE_HALF_EXPANDED);
+ }
+
+ @Override
+ public void onSlide(@NonNull View bottomSheet, float slideOffset)
+ {
+ sheetSlideHelper.onSlide(slideOffset);
+ }
+ };
+
+ public SearchBottomSheetBehavior(@NonNull Context context, @Nullable AttributeSet attrs)
+ {
+ super(context, attrs);
+ }
+
+ public static SearchBottomSheetBehavior from(@NonNull V view, Lifecycle lifecycle, float baseHalfExpandedRatio)
+ {
+ SearchBottomSheetBehavior result = (SearchBottomSheetBehavior) SearchBottomSheetBehavior.from(view);
+ lifecycle.addObserver((LifecycleEventObserver) (lifecycleOwner, event) -> {
+ switch (event)
+ {
+ case ON_START -> result.onStart();
+ case ON_STOP -> result.onStop();
+ }
+ });
+ result.mHalfExpandedBaseRatio = Math.min(0.999f, baseHalfExpandedRatio); // Limit the ratio to the maximum allowed value of less than 1.0f
+ result.mHalfExpandedRatio = result.mHalfExpandedBaseRatio;
+ return result;
+ }
+
+ @Override
+ public void setState(int state)
+ {
+ if (state == STATE_HALF_EXPANDED)
+ mHalfExpandedRatio = getHalfExpandedRatio();
+ super.setState(state);
+ }
+
+ public void onStart()
+ {
+ addBottomSheetCallback(easierCollapseCallback);
+ }
+
+ private void onStop()
+ {
+ removeBottomSheetCallback(easierCollapseCallback);
+ }
+
+ public void updateUnavailableScreenRatio(float unavailableRatio)
+ {
+ if (unavailableRatio < 0)
+ return;
+ setHalfExpandedRatio(Math.min(0.999f, mHalfExpandedBaseRatio + unavailableRatio));
+ if (getState() == STATE_HALF_EXPANDED)
+ super.setState(BottomSheetBehavior.STATE_HALF_EXPANDED); // intended super call; calling self setState would defeat the purpose of mHalfExpandedRatio
+ }
+
+ private interface SheetCollapseHelper
+ {
+ void onSlide(float slideOffset);
+ }
+}
diff --git a/android/app/src/main/res/drawable/bg_modal_search_header.xml b/android/app/src/main/res/drawable/bg_modal_search_header.xml
new file mode 100644
index 0000000000..b5232090ea
--- /dev/null
+++ b/android/app/src/main/res/drawable/bg_modal_search_header.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_map.xml b/android/app/src/main/res/layout/activity_map.xml
index abe4998ff8..a1d91082ac 100644
--- a/android/app/src/main/res/layout/activity_map.xml
+++ b/android/app/src/main/res/layout/activity_map.xml
@@ -55,4 +55,9 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="app.organicmaps.widget.placepage.PlacePageController" />
+
diff --git a/android/app/src/main/res/layout/fragment_search.xml b/android/app/src/main/res/layout/fragment_search.xml
index 531b2a9dea..9d62fdf024 100644
--- a/android/app/src/main/res/layout/fragment_search.xml
+++ b/android/app/src/main/res/layout/fragment_search.xml
@@ -29,7 +29,8 @@
+ app:layout_behavior="@string/appbar_scrolling_view_behavior"
+ android:nestedScrollingEnabled="true">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values-land/dimens.xml b/android/app/src/main/res/values-land/dimens.xml
index a016eca294..11ec1fbeb6 100644
--- a/android/app/src/main/res/values-land/dimens.xml
+++ b/android/app/src/main/res/values-land/dimens.xml
@@ -17,4 +17,7 @@
310dp
+
+
+ @dimen/panel_width
diff --git a/android/app/src/main/res/values-land/fraction.xml b/android/app/src/main/res/values-land/fraction.xml
new file mode 100644
index 0000000000..68ed33d8a6
--- /dev/null
+++ b/android/app/src/main/res/values-land/fraction.xml
@@ -0,0 +1,5 @@
+
+
+
+ 100%
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values-w1020dp-land/fraction.xml b/android/app/src/main/res/values-w1020dp-land/fraction.xml
new file mode 100644
index 0000000000..df2ebb1e1d
--- /dev/null
+++ b/android/app/src/main/res/values-w1020dp-land/fraction.xml
@@ -0,0 +1,5 @@
+
+
+
+ 50%
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml
index 8ee79ed8dd..e4626f1faf 100644
--- a/android/app/src/main/res/values/dimens.xml
+++ b/android/app/src/main/res/values/dimens.xml
@@ -179,4 +179,7 @@
310dp
+
+
+ -1.0px
diff --git a/android/app/src/main/res/values/donottranslate.xml b/android/app/src/main/res/values/donottranslate.xml
index 976c1ecd7c..48f5aa1357 100644
--- a/android/app/src/main/res/values/donottranslate.xml
+++ b/android/app/src/main/res/values/donottranslate.xml
@@ -66,6 +66,7 @@
AUTO
com.google.android.material.bottomsheet.BottomSheetBehavior
+ app.organicmaps.widget.modalsearch.SearchBottomSheetBehavior
Car
diff --git a/android/app/src/main/res/values/fraction.xml b/android/app/src/main/res/values/fraction.xml
new file mode 100644
index 0000000000..df2ebb1e1d
--- /dev/null
+++ b/android/app/src/main/res/values/fraction.xml
@@ -0,0 +1,5 @@
+
+
+
+ 50%
+
\ No newline at end of file