[android] Add modal search

Signed-off-by: savsch <119003089+savsch@users.noreply.github.com>
This commit is contained in:
Tanmay Gupta 2025-03-10 14:03:07 +05:30
parent 466b9365f6
commit 5f503c5c25
19 changed files with 691 additions and 87 deletions

View file

@ -380,14 +380,6 @@
android:parentActivityName="app.organicmaps.MwmActivity"
android:windowSoftInputMode="adjustResize" />
<activity
android:name="app.organicmaps.search.SearchActivity"
android:configChanges="orientation|screenLayout|screenSize"
android:screenOrientation="fullUser"
android:label="@string/search_map"
android:parentActivityName="app.organicmaps.MwmActivity"
android:windowSoftInputMode="stateVisible|adjustResize" />
<activity
android:name="app.organicmaps.settings.SettingsActivity"
android:configChanges="orientation|screenLayout|screenSize"

View file

@ -88,7 +88,6 @@ import app.organicmaps.routing.RoutingOptions;
import app.organicmaps.routing.RoutingPlanFragment;
import app.organicmaps.routing.RoutingPlanInplaceController;
import app.organicmaps.search.FloatingSearchToolbarController;
import app.organicmaps.search.SearchActivity;
import app.organicmaps.sdk.search.SearchEngine;
import app.organicmaps.search.SearchFragment;
import app.organicmaps.settings.DrivingOptionsActivity;
@ -108,6 +107,8 @@ import app.organicmaps.util.bottomsheet.MenuBottomSheetItem;
import app.organicmaps.util.log.Logger;
import app.organicmaps.widget.StackedButtonsDialog;
import app.organicmaps.widget.menu.MainMenu;
import app.organicmaps.widget.modalsearch.ModalSearchController;
import app.organicmaps.widget.modalsearch.ModalSearchViewModel;
import app.organicmaps.widget.placepage.PlacePageController;
import app.organicmaps.widget.placepage.PlacePageData;
import app.organicmaps.widget.placepage.PlacePageViewModel;
@ -188,6 +189,7 @@ public class MwmActivity extends BaseMwmFragmentActivity
@SuppressWarnings("NotNullFieldNotInitialized")
@NonNull
private FloatingSearchToolbarController mSearchController;
private ModalSearchViewModel mSearchViewModel;
private boolean mRestoreRoutingPlanFragmentNeeded;
@Nullable
@ -423,12 +425,12 @@ public class MwmActivity extends BaseMwmFragmentActivity
if (mIsTabletLayout)
{
final Bundle args = new Bundle();
args.putString(SearchActivity.EXTRA_QUERY, query);
args.putString(SearchFragment.ARG_QUERY, query);
replaceFragment(SearchFragment.class, args, null);
}
else
{
SearchActivity.start(this, query);
mSearchViewModel.setModalSearchActive(true);
}
}
@ -464,9 +466,9 @@ public class MwmActivity extends BaseMwmFragmentActivity
{
final Bundle args = new Bundle();
args.putBoolean(DownloaderActivity.EXTRA_OPEN_DOWNLOADED, openDownloaded);
closeSearchToolbar(false, true);
if (mIsTabletLayout)
{
closeSearchToolbar(false, true);
replaceFragment(DownloaderFragment.class, args, null);
}
else
@ -533,6 +535,7 @@ public class MwmActivity extends BaseMwmFragmentActivity
// We don't need to manually handle removing the observers it follows the activity lifecycle
mMapButtonsViewModel.getBottomButtonsHeight().observe(this, this::onMapBottomButtonsHeightChange);
mMapButtonsViewModel.getLayoutMode().observe(this, this::initNavigationButtons);
mSearchViewModel = new ViewModelProvider(MwmActivity.this).get(ModalSearchViewModel.class);
mSearchController = new FloatingSearchToolbarController(this, this);
mSearchController.getToolbar()
@ -692,7 +695,7 @@ public class MwmActivity extends BaseMwmFragmentActivity
showSearchToolbar();
}
}
else
else if (mIsTabletLayout)
{
closeSearchToolbar(true, true);
}
@ -906,7 +909,16 @@ public class MwmActivity extends BaseMwmFragmentActivity
*/
private boolean closeSearchToolbar(boolean clearText, boolean stopSearch)
{
if (UiUtils.isVisible(mSearchController.getToolbar()) || !TextUtils.isEmpty(SearchEngine.INSTANCE.getQuery()))
if (!mIsTabletLayout && stopSearch && Boolean.TRUE.equals(mSearchViewModel.getModalSearchActive().getValue()))
{
mSearchViewModel.setModalSearchActive(false);
return true;
}
else if (
UiUtils.isVisible(mSearchController.getToolbar())
|| !TextUtils.isEmpty(SearchEngine.INSTANCE.getQuery())
|| Boolean.TRUE.equals(mSearchViewModel.getModalSearchActive().getValue())
)
{
if (stopSearch)
{
@ -970,7 +982,10 @@ public class MwmActivity extends BaseMwmFragmentActivity
mMainMenu = new MainMenu(menuFrame, (visible) -> {
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();

View file

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

View file

@ -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;
}
}

View file

@ -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<HiddenCommand> 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()

View file

@ -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<NestedScrollView> mSearchBehavior;
private NestedScrollView mModalSearch;
private ViewGroup mCoordinator;
private int mViewportMinHeight;
private ModalSearchViewModel mViewModel;
private final Observer<Boolean> 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<Integer> 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<Boolean> mModalSearchActiveObserver = active -> {
if (active)
startSearch(null);
else
closeSearch();
};
private final Observer<Boolean> 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<Boolean> 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);
}
}

View file

@ -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;
}
}

View file

@ -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<Integer> mModalSearchDistanceToTop = new MutableLiveData<>();
private final MutableLiveData<Boolean> mModalSearchActive = new MutableLiveData<>();
private final MutableLiveData<Boolean> mModalSearchSuspended = new MutableLiveData<>();
private final MutableLiveData<Boolean> mModalSearchCollapsed = new MutableLiveData<>();
private final MutableLiveData<Boolean> mIsQueryEmpty = new MutableLiveData<>(true);
public MutableLiveData<Integer> getModalSearchDistanceToTop()
{
return mModalSearchDistanceToTop;
}
public void setModalSearchDistanceToTop(int top)
{
mModalSearchDistanceToTop.setValue(top);
}
public MutableLiveData<Boolean> getModalSearchActive()
{
return mModalSearchActive;
}
public void setModalSearchActive(Boolean active)
{
mModalSearchActive.setValue(active);
}
public MutableLiveData<Boolean> getModalSearchSuspended()
{
return mModalSearchSuspended;
}
public void setModalSearchSuspended(Boolean suspended)
{
mModalSearchSuspended.setValue(suspended);
}
public MutableLiveData<Boolean> getModalSearchCollapsed()
{
return mModalSearchCollapsed;
}
public void setModalSearchCollapsed(Boolean collapsed)
{
mModalSearchCollapsed.setValue(collapsed);
}
public MutableLiveData<Boolean> getIsQueryEmpty()
{
return mIsQueryEmpty;
}
public void setIsQueryEmpty(Boolean isQueryEmpty)
{
mIsQueryEmpty.setValue(isQueryEmpty);
}
}

View file

@ -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<V extends View> extends BottomSheetBehavior<V>
{
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 <V extends View> SearchBottomSheetBehavior<V> from(@NonNull V view, Lifecycle lifecycle, float baseHalfExpandedRatio)
{
SearchBottomSheetBehavior<V> result = (SearchBottomSheetBehavior<V>) 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);
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/colorPrimary" />
<corners android:topLeftRadius="@dimen/margin_half" android:topRightRadius="@dimen/margin_half" />
</shape>

View file

@ -55,4 +55,9 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:name="app.organicmaps.widget.placepage.PlacePageController" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/modal_search_container_fragment"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:name="app.organicmaps.widget.modalsearch.ModalSearchController" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -29,7 +29,8 @@
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:nestedScrollingEnabled="true">
<!-- Tabs -->
<LinearLayout
android:id="@+id/tab_frame"

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:id="@+id/modal_search"
style="?attr/bottomSheetStyle"
android:maxWidth="@dimen/max_search_sheet_width"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="0dp"
android:fillViewport="true"
app:layout_behavior="@string/search_sheet_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<FrameLayout
android:id="@+id/drag_indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_modal_search_header">
<ImageView
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_marginBottom="@dimen/margin_quarter_plus"
android:layout_marginTop="@dimen/margin_quarter"
app:srcCompat="@drawable/bottom_sheet_handle"
app:tint="?colorControlHighlight" />
</FrameLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/modal_search_fragment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:tag="SearchFragment" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -17,4 +17,7 @@
<!--About Fragment -->
<dimen name="about_max_button_width">310dp</dimen>
<!-- Search Bottom Sheet-->
<dimen name="max_search_sheet_width">@dimen/panel_width</dimen>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- ModalSearch Bottom Sheet-->
<fraction name="modal_search_half_expanded_ratio">100%</fraction> <!-- Skips the half-expanded state in landscape mode -->
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- ModalSearch Bottom Sheet-->
<fraction name="modal_search_half_expanded_ratio">50%</fraction>
</resources>

View file

@ -179,4 +179,7 @@
<!--About Fragment -->
<dimen name="about_max_button_width">310dp</dimen>
<!-- Search Bottom Sheet-->
<dimen name="max_search_sheet_width">-1.0px</dimen>
</resources>

View file

@ -66,6 +66,7 @@
<string name="auto_enum_value" translatable="false">AUTO</string>
<string name="placepage_behavior" translatable="false">com.google.android.material.bottomsheet.BottomSheetBehavior</string>
<string name="search_sheet_behavior" translatable="false">app.organicmaps.widget.modalsearch.SearchBottomSheetBehavior</string>
<string name="car_notification_channel_name" translatable="false">Car</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- ModalSearch Bottom Sheet-->
<fraction name="modal_search_half_expanded_ratio">50%</fraction>
</resources>