Add support for the Material Design 3 FAB Menu component

Co-authored-by: Michael Schättgen <michael@schattgen.me>
pull/1743/head
Artem Klyuev 3 months ago committed by Michael Schättgen
parent 491a81584d
commit 276cdb584a

@ -186,7 +186,7 @@ public class OverallTest extends AegisTest {
private void addEntry(VaultEntry entry) {
onView(withId(R.id.fab)).perform(click());
onView(withId(R.id.fab_enter)).perform(click());
onView(withId(R.id.fab_menu_item_enter)).perform(click());
onView(withId(R.id.accordian_header)).perform(scrollTo(), click());
onView(withId(R.id.text_name)).perform(typeText(entry.getName()), closeSoftKeyboard());

@ -0,0 +1,193 @@
package com.beemdevelopment.aegis.helpers;
import android.animation.ValueAnimator;
import android.graphics.Matrix;
import android.graphics.drawable.Drawable;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.OvershootInterpolator;
import android.widget.ImageView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
public class FabMenuHelper {
private final static long ANIMATION_DURATION = 300L;
private final static long ANIMATION_ACTION_DELAY = 50L;
private final View _scrim;
private final View _menuItemsContainer;
private final FloatingActionButton _mainFab;
private final List<View> _actions;
private Consumer<Boolean> _stateListener;
private boolean _isOpen = false;
public FabMenuHelper(
View scrim,
ViewGroup menuItemsContainer,
FloatingActionButton fab,
Map<View, Runnable> actions
) {
_scrim = scrim;
_menuItemsContainer = menuItemsContainer;
_mainFab = fab;
_actions = new ArrayList<>(actions.keySet());
for (View action : _actions) {
action.setVisibility(View.GONE);
action.setAlpha(0f);
action.setScaleX(0f);
action.setScaleY(0f);
}
setupClickListeners(actions);
}
public void setOnFabMenuStateChangeListener(Consumer<Boolean> listener) {
_stateListener = listener;
}
private void setupClickListeners(Map<View, Runnable> actions) {
_mainFab.setOnClickListener(v -> toggle());
_scrim.setOnClickListener(v -> close());
actions.forEach((action, onClick) -> {
action.setOnClickListener(v -> {
if (onClick != null) {
onClick.run();
}
close();
});
});
}
public void toggle() {
if (_isOpen) {
close();
} else {
open();
}
}
public void open() {
if (_isOpen) {
return;
}
_isOpen = true;
_scrim.animate()
.alpha(0.5f)
.setDuration(ANIMATION_DURATION)
.withStartAction(() -> _scrim.setVisibility(View.VISIBLE))
.start();
_menuItemsContainer.setVisibility(View.VISIBLE);
long delay = 0L;
for (int i = _actions.size() - 1; i >= 0; i--) {
animateActionIn(_actions.get(i), delay);
delay += ANIMATION_ACTION_DELAY;
}
animateFabIconForward(_mainFab);
if (_stateListener != null) {
_stateListener.accept(true);
}
}
public void close() {
if (!_isOpen) {
return;
}
_isOpen = false;
_scrim.animate()
.alpha(0f)
.setDuration(ANIMATION_DURATION)
.withEndAction(() -> _scrim.setVisibility(View.GONE))
.start();
long delay = 0L;
for (View action : _actions) {
animateActionOut(action, delay);
delay += ANIMATION_ACTION_DELAY;
}
animateFabIconBackward(_mainFab);
_mainFab.postDelayed(() -> {
if (!_isOpen) {
_menuItemsContainer.setVisibility(View.GONE);
}
}, ANIMATION_DURATION);
if (_stateListener != null) {
_stateListener.accept(false);
}
}
private void animateFabIconForward(FloatingActionButton fab) {
animateFabIcon(fab, 0f, 45f);
}
private void animateFabIconBackward(FloatingActionButton fab) {
animateFabIcon(fab, 45f, 0f);
}
private void animateFabIcon(FloatingActionButton fab, float from, float to) {
Drawable drawable = _mainFab.getDrawable();
int width = drawable.getIntrinsicWidth();
int height = drawable.getIntrinsicHeight();
fab.setScaleType(ImageView.ScaleType.MATRIX);
Matrix matrix = new Matrix();
ValueAnimator anim = ValueAnimator.ofFloat(from, to);
anim.setDuration(100L);
anim.addUpdateListener(valueAnimator -> {
Float angle = (Float) valueAnimator.getAnimatedValue();
matrix.reset();
matrix.postRotate(angle, width / 2f, height / 2f);
fab.setImageMatrix(matrix);
});
anim.start();
}
private void animateActionIn(View action, long delay) {
action.setVisibility(View.VISIBLE);
action.setAlpha(0f);
action.setScaleX(0.4f);
action.setScaleY(0.4f);
action.animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(ANIMATION_DURATION)
.setStartDelay(delay)
.setInterpolator(new OvershootInterpolator(1.2f))
.start();
}
private void animateActionOut(View action, long delay) {
action.animate()
.alpha(0f)
.scaleX(0f)
.scaleY(0f)
.setDuration(ANIMATION_DURATION)
.setStartDelay(delay)
.withEndAction(() -> action.setVisibility(View.GONE))
.start();
}
public boolean isOpen() {
return _isOpen;
}
}

@ -23,6 +23,7 @@ import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.AutoCompleteTextView;
import android.widget.Button;
@ -46,6 +47,7 @@ import com.beemdevelopment.aegis.R;
import com.beemdevelopment.aegis.SortCategory;
import com.beemdevelopment.aegis.helpers.BitmapHelper;
import com.beemdevelopment.aegis.helpers.DropdownHelper;
import com.beemdevelopment.aegis.helpers.FabMenuHelper;
import com.beemdevelopment.aegis.helpers.FabScrollHelper;
import com.beemdevelopment.aegis.helpers.PermissionHelper;
import com.beemdevelopment.aegis.helpers.ViewHelper;
@ -69,7 +71,6 @@ import com.beemdevelopment.aegis.vault.VaultFile;
import com.beemdevelopment.aegis.vault.VaultGroup;
import com.beemdevelopment.aegis.vault.VaultRepository;
import com.beemdevelopment.aegis.vault.VaultRepositoryException;
import com.google.android.material.bottomsheet.BottomSheetDialog;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.color.MaterialColors;
@ -84,9 +85,11 @@ import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.SequencedMap;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
@ -117,6 +120,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
private Set<UUID> _prefGroupFilter;
private FabScrollHelper _fabScrollHelper;
private FabMenuHelper _fabMenuHelper;
private ActionMode _actionMode;
private ActionMode.Callback _actionModeCallbacks = new ActionModeCallbacks();
@ -124,6 +128,7 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
private LockBackPressHandler _lockBackPressHandler;
private SearchViewBackPressHandler _searchViewBackPressHandler;
private ActionModeBackPressHandler _actionModeBackPressHandler;
private FabMenuBackPressHandler _fabMenuBackPressHandler;
private final ActivityResultLauncher<Intent> authResultLauncher =
registerForActivityResult(new StartActivityForResult(), activityResult -> {
@ -207,6 +212,8 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
getOnBackPressedDispatcher().addCallback(this, _searchViewBackPressHandler);
_actionModeBackPressHandler = new ActionModeBackPressHandler();
getOnBackPressedDispatcher().addCallback(this, _actionModeBackPressHandler);
_fabMenuBackPressHandler = new FabMenuBackPressHandler();
getOnBackPressedDispatcher().addCallback(this, _fabMenuBackPressHandler);
_entryListView = (EntryListView) getSupportFragmentManager().findFragmentById(R.id.key_profiles);
_entryListView.setListener(this);
@ -226,27 +233,29 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
_entryListView.setSearchBehaviorMask(_prefs.getSearchBehaviorMask());
_prefGroupFilter = _prefs.getGroupFilter();
FloatingActionButton fab = findViewById(R.id.fab);
fab.setOnClickListener(v -> {
View view = getLayoutInflater().inflate(R.layout.dialog_add_entry, null);
BottomSheetDialog dialog = new BottomSheetDialog(this);
dialog.setContentView(view);
view.findViewById(R.id.fab_enter).setOnClickListener(v1 -> {
dialog.dismiss();
startEditEntryActivityForManual();
});
view.findViewById(R.id.fab_scan_image).setOnClickListener(v2 -> {
dialog.dismiss();
startScanImageActivity();
});
view.findViewById(R.id.fab_scan).setOnClickListener(v3 -> {
dialog.dismiss();
startScanActivity();
});
Dialogs.showSecureDialog(dialog);
});
View scrimOverlayLayout = LayoutInflater.from(this).inflate(R.layout.scrim_layout, null);
View scrimOverlay = scrimOverlayLayout.findViewById(R.id.scrim);
addContentView(scrimOverlayLayout, new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
View fabMenuLayout = LayoutInflater.from(this).inflate(R.layout.fab_menu, null);
addContentView(fabMenuLayout, new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
));
ViewGroup menuItemsContainer = fabMenuLayout.findViewById(R.id.fab_menu_items_container);
FloatingActionButton fab = fabMenuLayout.findViewById(R.id.fab);
LinkedHashMap<View, Runnable> actions = new LinkedHashMap<>();
actions.put(fabMenuLayout.findViewById(R.id.fab_menu_item_scan), this::startScanActivity);
actions.put(fabMenuLayout.findViewById(R.id.fab_menu_item_scan_image), this::startScanImageActivity);
actions.put(fabMenuLayout.findViewById(R.id.fab_menu_item_enter), this::startEditEntryActivityForManual);
_fabMenuHelper = new FabMenuHelper(scrimOverlay, menuItemsContainer, fab, actions);
_fabMenuHelper.setOnFabMenuStateChangeListener(_fabMenuBackPressHandler::setEnabled);
_groupChip = findViewById(R.id.groupChipGroup);
_fabScrollHelper = new FabScrollHelper(fab);
@ -1314,6 +1323,9 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
if (_searchView != null && !_searchView.isIconified()) {
collapseSearchView();
}
if (_fabMenuHelper != null && _fabMenuHelper.isOpen()) {
_fabMenuHelper.close();
}
_entryListView.clearEntries();
_loaded = false;
@ -1399,6 +1411,19 @@ public class MainActivity extends AegisActivity implements EntryListView.Listene
}
}
private class FabMenuBackPressHandler extends OnBackPressedCallback {
public FabMenuBackPressHandler() {
super(false);
}
@Override
public void handleOnBackPressed() {
if (_fabMenuHelper != null && _fabMenuHelper.isOpen()) {
_fabMenuHelper.close();
}
}
}
private class ActionModeCallbacks implements ActionMode.Callback {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {

@ -59,12 +59,4 @@
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
android:src="@drawable/ic_outline_add_24" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,141 @@
<?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"
android:fitsSystemWindows="true"
android:clipChildren="false"
android:clipToPadding="false">
<LinearLayout
android:id="@+id/fab_menu_items_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginHorizontal="@dimen/fab_margin"
android:layout_marginBottom="75dp"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical"
android:visibility="gone">
<com.google.android.material.card.MaterialCardView
android:id="@+id/fab_menu_item_scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginBottom="6dp"
app:cardCornerRadius="100dp"
app:cardElevation="6dp"
app:contentPadding="6dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="48dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp">
<ImageView
android:layout_width="@dimen/fab_menu_item_image_size"
android:layout_height="@dimen/fab_menu_item_image_size"
android:src="@drawable/ic_qrcode_scan" />
<Space
android:layout_width="12dp"
android:layout_height="0dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan"
android:textAppearance="?attr/textAppearanceLargePopupMenu" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/fab_menu_item_scan_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginBottom="6dp"
app:cardCornerRadius="100dp"
app:cardElevation="6dp"
app:contentPadding="6dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="48dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp">
<ImageView
android:layout_width="@dimen/fab_menu_item_image_size"
android:layout_height="@dimen/fab_menu_item_image_size"
android:src="@drawable/ic_outline_add_photo_alternate_24" />
<Space
android:layout_width="12dp"
android:layout_height="0dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan_image"
android:textAppearance="?attr/textAppearanceLargePopupMenu" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/fab_menu_item_enter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginBottom="6dp"
app:cardCornerRadius="100dp"
app:cardElevation="6dp"
app:contentPadding="6dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="48dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp">
<ImageView
android:layout_width="@dimen/fab_menu_item_image_size"
android:layout_height="@dimen/fab_menu_item_image_size"
android:src="@drawable/ic_outline_edit_24" />
<Space
android:layout_width="12dp"
android:layout_height="0dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/enter_manually"
android:textAppearance="?attr/textAppearanceLargePopupMenu" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
android:contentDescription="@string/add_new_entry"
android:src="@drawable/ic_outline_add_24" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<View
android:id="@+id/scrim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000"
android:alpha="0"
android:clickable="true"
android:focusable="true"
android:visibility="gone" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -1,4 +1,5 @@
<resources>
<dimen name="fab_margin">16dp</dimen>
<dimen name="fab_menu_item_image_size">24dp</dimen>
<dimen name="list_item_height">48dp</dimen>
</resources>

Loading…
Cancel
Save