Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
c1a02e88ba [android] Retain SearchFragment search results across configuration changes
Signed-off-by: savsch <119003089+savsch@users.noreply.github.com>
2025-03-11 22:59:44 +05:30
5f503c5c25 [android] Add modal search
Signed-off-by: savsch <119003089+savsch@users.noreply.github.com>
2025-03-11 19:30:25 +05:30
22 changed files with 867 additions and 95 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

@ -2,6 +2,8 @@ package app.organicmaps.sdk.search;
import android.content.Context;
import android.graphics.Typeface;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
@ -20,7 +22,7 @@ import app.organicmaps.util.Distance;
// Used by JNI.
@Keep
@SuppressWarnings("unused")
public class SearchResult
public class SearchResult implements Parcelable
{
public static final int TYPE_PURE_SUGGEST = 0;
public static final int TYPE_SUGGEST = 1;
@ -37,7 +39,7 @@ public class SearchResult
// Used by JNI.
@Keep
@SuppressWarnings("unused")
public static class Description
public static class Description implements Parcelable
{
public final FeatureId featureId;
public final String localizedFeatureType;
@ -65,6 +67,54 @@ public class SearchResult
this.minutesUntilClosed = minutesUntilClosed;
this.hasPopularityHigherPriority = hasPopularityHigherPriority;
}
public static final Creator<Description> CREATOR = new Creator<Description>()
{
@Override
public Description createFromParcel(Parcel in)
{
return new Description(in);
}
@Override
public Description[] newArray(int size)
{
return new Description[size];
}
};
protected Description(Parcel in)
{
featureId = in.readParcelable(FeatureId.class.getClassLoader());
localizedFeatureType = in.readString();
region = in.readString();
distance = in.readParcelable(Distance.class.getClassLoader());
description = in.readString();
openNow = in.readInt();
minutesUntilOpen = in.readInt();
minutesUntilClosed = in.readInt();
hasPopularityHigherPriority = in.readByte() != 0;
}
@Override
public void writeToParcel(Parcel dest, int flags)
{
dest.writeParcelable(featureId, flags);
dest.writeString(localizedFeatureType);
dest.writeString(region);
dest.writeParcelable(distance, flags);
dest.writeString(description);
dest.writeInt(openNow);
dest.writeInt(minutesUntilOpen);
dest.writeInt(minutesUntilClosed);
dest.writeByte((byte) (hasPopularityHigherPriority ? 1 : 0));
}
@Override
public int describeContents()
{
return 0;
}
}
public final String name;
@ -113,6 +163,19 @@ public class SearchResult
this.descHighlightRanges = descHighlightRanges;
}
protected SearchResult(Parcel in)
{
name = in.readString();
suggestion = in.readString();
lat = in.readDouble();
lon = in.readDouble();
type = in.readInt();
description = in.readParcelable(Description.class.getClassLoader());
highlightRanges = in.createIntArray();
descHighlightRanges = in.createIntArray();
mPopularity = in.readParcelable(Popularity.class.getClassLoader());
}
@NonNull
public String getTitle(@NonNull Context context)
{
@ -157,4 +220,38 @@ public class SearchResult
return builder;
}
}
public static final Creator<SearchResult> CREATOR = new Creator<>()
{
@Override
public SearchResult createFromParcel(Parcel in)
{
return new SearchResult(in);
}
@Override
public SearchResult[] newArray(int size)
{
return new SearchResult[size];
}
};
@Override
public int describeContents()
{
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags)
{
dest.writeString(name);
dest.writeString(suggestion);
dest.writeDouble(lat);
dest.writeDouble(lon);
dest.writeInt(type);
dest.writeParcelable(description, flags);
dest.writeIntArray(highlightRanges);
dest.writeIntArray(descHighlightRanges);
dest.writeParcelable(mPopularity, flags);
}
}

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

@ -256,6 +256,11 @@ class SearchAdapter extends RecyclerView.Adapter<SearchAdapter.SearchDataViewHol
refreshData(null);
}
public SearchResult[] getResults()
{
return mResults;
}
void refreshData(@Nullable SearchResult[] results)
{
mResults = results;

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,7 +61,15 @@ 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 static final String STATE_KEY_RESULTS = "state_results";
private ModalSearchViewModel mViewModel;
private long mLastQueryTimestamp;
private boolean wereResultsRestored = false;
@NonNull
private final List<HiddenCommand> mHiddenCommands = new ArrayList<>();
@ -78,9 +89,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 +112,7 @@ public class SearchFragment extends BaseMwmFragment
if (TextUtils.isEmpty(query))
{
mSearchAdapter.clear();
mViewModel.setIsQueryEmpty(true);
stopSearch();
return;
}
@ -104,7 +125,12 @@ public class SearchFragment extends BaseMwmFragment
return;
}
runSearch();
mViewModel.setIsQueryEmpty(false);
if (wereResultsRestored)
wereResultsRestored = false;
else
runSearch();
}
@Override
@ -170,6 +196,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 +257,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(wereResultsRestored || hasQuery, mResultsFrame);
UiUtils.showIf(!mIsModal && hasQuery, mShowOnMapFab);
if (hasQuery)
hideDownloadSuggest();
else if (doShowDownloadSuggest())
@ -248,7 +275,7 @@ public class SearchFragment extends BaseMwmFragment
{
final boolean show = !mSearchRunning
&& mSearchAdapter.getItemCount() == 0
&& mToolbarController.hasQuery();
&& (wereResultsRestored || mToolbarController.hasQuery());
UiUtils.showIf(show, mResultsPlaceholder);
}
@ -256,6 +283,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 +299,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())
@ -301,6 +332,16 @@ public class SearchFragment extends BaseMwmFragment
mResults.setLayoutManager(new LinearLayoutManager(view.getContext()));
mResults.setAdapter(mSearchAdapter);
if (savedInstanceState != null)
{
SearchResult[] savedSearchResults = (SearchResult[]) savedInstanceState.getParcelableArray(STATE_KEY_RESULTS);
if (savedSearchResults != null)
{
wereResultsRestored = true;
mSearchAdapter.refreshData(savedSearchResults);
}
}
updateFrames();
updateResultsPlaceholder();
ViewCompat.setOnApplyWindowInsetsListener(
@ -320,6 +361,11 @@ public class SearchFragment extends BaseMwmFragment
if (mInitialSearchOnMap)
showAllResultsOnMap();
view.findViewById(R.id.query).setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus)
mViewModel.setModalSearchCollapsed(false);
});
}
@Override
@ -353,6 +399,14 @@ public class SearchFragment extends BaseMwmFragment
mToolbarController.detach();
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState)
{
super.onSaveInstanceState(outState);
if (!mSearchRunning)
outState.putParcelableArray(STATE_KEY_RESULTS, mSearchAdapter.getResults());
}
@Override
public void onDestroy()
{
@ -374,9 +428,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 +475,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 +536,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 +598,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

@ -1,6 +1,8 @@
package app.organicmaps.util;
import android.content.Context;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
@ -11,7 +13,7 @@ import app.organicmaps.R;
// Used by JNI.
@Keep
@SuppressWarnings("unused")
public final class Distance
public final class Distance implements Parcelable
{
public static final Distance EMPTY = new Distance(0.0, "", (byte) 0);
@ -50,6 +52,13 @@ public final class Distance
mUnits = Units.values()[unitsIndex];
}
protected Distance(Parcel in)
{
mDistance = in.readDouble();
mDistanceStr = in.readString();
mUnits = Units.values()[in.readByte()];
}
public boolean isValid()
{
return mDistance >= 0.0;
@ -70,6 +79,21 @@ public final class Distance
return mDistanceStr + NON_BREAKING_SPACE + getUnitsStr(context);
}
public static final Creator<Distance> CREATOR = new Creator<Distance>()
{
@Override
public Distance createFromParcel(Parcel in)
{
return new Distance(in);
}
@Override
public Distance[] newArray(int size)
{
return new Distance[size];
}
};
@NonNull
@Override
public String toString()
@ -79,4 +103,18 @@ public final class Distance
return mDistanceStr + NON_BREAKING_SPACE + mUnits.toString();
}
}
@Override
public int describeContents()
{
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags)
{
dest.writeDouble(mDistance);
dest.writeString(mDistanceStr);
dest.writeByte((byte) mUnits.ordinal());
}
}

View file

@ -0,0 +1,308 @@
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 static final int FALLBACK_COLLAPSED_HEIGHT = 200;
private SearchBottomSheetBehavior<NestedScrollView> mSearchBehavior;
private NestedScrollView mModalSearch;
private ViewGroup mCoordinator;
private int mViewportMinHeight;
private int mCollapsedHeight = FALLBACK_COLLAPSED_HEIGHT;
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
{
int calculatedHeight = mDragIndicator.getMeasuredHeight() +
mModalSearch.findViewById(R.id.app_bar).getMeasuredHeight(); // TODO(savsch) get feedback on whether to change this height
if (calculatedHeight > 0)
mCollapsedHeight = calculatedHeight;
} catch (NullPointerException ignored)
{}
return mCollapsedHeight;
}
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>