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/sdk/search/SearchResult.java b/android/app/src/main/java/app/organicmaps/sdk/search/SearchResult.java index 2fdcf6861a..4c5ad8501d 100644 --- a/android/app/src/main/java/app/organicmaps/sdk/search/SearchResult.java +++ b/android/app/src/main/java/app/organicmaps/sdk/search/SearchResult.java @@ -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 CREATOR = new Creator() + { + @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 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); + } +} \ No newline at end of file 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/SearchAdapter.java b/android/app/src/main/java/app/organicmaps/search/SearchAdapter.java index 261d86c44e..0728822627 100644 --- a/android/app/src/main/java/app/organicmaps/search/SearchAdapter.java +++ b/android/app/src/main/java/app/organicmaps/search/SearchAdapter.java @@ -256,6 +256,11 @@ class SearchAdapter extends RecyclerView.Adapter 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() diff --git a/android/app/src/main/java/app/organicmaps/util/Distance.java b/android/app/src/main/java/app/organicmaps/util/Distance.java index 0848fb9bf1..9dad3814ae 100644 --- a/android/app/src/main/java/app/organicmaps/util/Distance.java +++ b/android/app/src/main/java/app/organicmaps/util/Distance.java @@ -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 CREATOR = new Creator() + { + @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()); + } +} \ No newline at end of file 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..d72427aeb3 --- /dev/null +++ b/android/app/src/main/java/app/organicmaps/widget/modalsearch/ModalSearchController.java @@ -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 mSearchBehavior; + private NestedScrollView mModalSearch; + private ViewGroup mCoordinator; + private int mViewportMinHeight; + private int mCollapsedHeight = FALLBACK_COLLAPSED_HEIGHT; + 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 + { + 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); + } +} 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