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 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