diff --git a/src/Android/Android.csproj b/src/Android/Android.csproj index 7abf7d6dd..7b569ba8e 100644 --- a/src/Android/Android.csproj +++ b/src/Android/Android.csproj @@ -156,6 +156,8 @@ + + @@ -294,6 +296,7 @@ + \ No newline at end of file diff --git a/src/Android/Renderers/CollectionView/ExtendedCollectionViewRenderer.cs b/src/Android/Renderers/CollectionView/ExtendedCollectionViewRenderer.cs new file mode 100644 index 000000000..314a8e47d --- /dev/null +++ b/src/Android/Renderers/CollectionView/ExtendedCollectionViewRenderer.cs @@ -0,0 +1,87 @@ +using System; +using Android.Content; +using AndroidX.RecyclerView.Widget; +using Bit.App.Controls; +using Bit.App.Utilities; +using Bit.Core; +using Bit.Droid.Renderers.CollectionView; +using Bit.Droid.Utilities; +using Xamarin.Forms; +using Xamarin.Forms.Platform.Android; +using static Android.Content.ClipData; +using static AndroidX.RecyclerView.Widget.RecyclerView; + +[assembly: ExportRenderer(typeof(ExtendedCollectionView), typeof(ExtendedCollectionViewRenderer))] +namespace Bit.Droid.Renderers.CollectionView +{ + public class CustomGroupableItemsViewAdapter : GroupableItemsViewAdapter + where TItemsView : GroupableItemsView + where TItemsViewSource : IGroupableItemsViewSource + { + protected internal CustomGroupableItemsViewAdapter(TItemsView groupableItemsView, Func createView = null) + : base(groupableItemsView, createView) + { + } + + public object GetItemAt(int position) + { + return ItemsSource.GetItem(position); + } + } + + public class ExtendedCollectionViewRenderer : GroupableItemsViewRenderer, IGroupableItemsViewSource> + { + ItemTouchHelper _itemTouchHelper; + + public ExtendedCollectionViewRenderer(Context context) : base(context) + { + } + + protected override CustomGroupableItemsViewAdapter CreateAdapter() + { + return new CustomGroupableItemsViewAdapter(ItemsView); + } + + protected override void SetUpNewElement(ExtendedCollectionView newElement) + { + base.SetUpNewElement(newElement); + + if (newElement is null) + { + return; + } + + var itemTouchCallback = new RecyclerSwipeItemTouchCallback(ItemTouchHelper.Right, this.Context, new CipherViewModelSwipeableItem(), + viewHolder => + { + if (viewHolder is TemplatedItemViewHolder templatedViewHolder + && + templatedViewHolder.View?.BindingContext is CipherViewCellViewModel vm) + { + return vm; + } + return null; + }); + itemTouchCallback.OnSwipedCommand = new Command(viewHolder => + { + ItemsViewAdapter.NotifyItemChanged(viewHolder.LayoutPosition); + + ItemsView.OnSwipeCommand?.Execute(ItemsViewAdapter.GetItemAt(viewHolder.BindingAdapterPosition)); + }); + _itemTouchHelper = new ItemTouchHelper(itemTouchCallback); + _itemTouchHelper.AttachToRecyclerView(this); + } + + protected override void TearDownOldElement(ItemsView oldElement) + { + base.TearDownOldElement(oldElement); + + if (oldElement is null) + { + return; + } + + _itemTouchHelper.AttachToRecyclerView(null); + } + } +} diff --git a/src/Android/Utilities/RecyclerSwipeItemTouchCallback.cs b/src/Android/Utilities/RecyclerSwipeItemTouchCallback.cs new file mode 100644 index 000000000..c48e6c957 --- /dev/null +++ b/src/Android/Utilities/RecyclerSwipeItemTouchCallback.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Windows.Input; +using Android.Graphics; +using Android.Graphics.Drawables; +using Android.Util; +using Android.Views; +using AndroidX.RecyclerView.Widget; +using Bit.App.Abstractions; +using Xamarin.Forms.Platform.Android; +using FontImageSource = Xamarin.Forms.FontImageSource; + +namespace Bit.Droid.Utilities +{ + public class RecyclerSwipeItemTouchCallback : ItemTouchHelper.SimpleCallback + { + private Paint _clearPaint; + private readonly ColorDrawable _background = new ColorDrawable(); + private readonly Android.Content.Context _context; + private readonly ISwipeableItem _swipeableItem; + private readonly Func _viewHolderToTItem; + private Dictionary _fontFamilyTypefaceCache = new Dictionary(); + + public RecyclerSwipeItemTouchCallback(int swipeDir, Android.Content.Context context, ISwipeableItem swipeableItem, Func viewHolderToTItem) + : base(0, swipeDir) + { + _context = context; + _swipeableItem = swipeableItem; + _viewHolderToTItem = viewHolderToTItem; + + _clearPaint = new Paint(); + _clearPaint.SetXfermode(new PorterDuffXfermode(PorterDuff.Mode.Clear)); + } + + public ICommand OnSwipedCommand { get; set; } + + public override bool OnMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) + { + return false; + } + + public override void OnChildDrawOver(Canvas c, RecyclerView recyclerView, + RecyclerView.ViewHolder viewHolder, float dX, float dY, + int actionState, bool isCurrentlyActive) + { + + var itemView = viewHolder.ItemView; + int itemHeight = itemView.Bottom - itemView.Top; + var isCanceled = (dX == 0f) && !isCurrentlyActive; + + if (isCanceled) + { + ClearCanvas(c, itemView.Right + dX, itemView.Top, itemView.Right, itemView.Bottom); + base.OnChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, false); + return; + } + + if (!(_swipeableItem.GetSwipeIcon(_viewHolderToTItem(viewHolder)) is FontImageSource fontSource)) + { + return; + } + + using var paint = GetIconPaint(itemView, fontSource); + + var width = (int)(paint.MeasureText(fontSource.Glyph) + .5f); + var baseline = (int)(-paint.Ascent() + .5f); + var height = (int)(baseline + paint.Descent() + .5f); + + int itemTop = itemView.Top + (itemHeight - height) / 2; + int itemMargin = (itemHeight - height) / 2; + int itemBottom = itemTop + height; + + _background.Color = _swipeableItem.GetBackgroundColor(_viewHolderToTItem(viewHolder)).ToAndroid(); + if (dX < 0) + { + _background.SetBounds((int)(itemView.Right + dX), itemView.Top, itemView.Right, itemView.Bottom); + _background.Draw(c); + + int itemLeft = itemView.Right - itemMargin - width; + int itemRight = itemView.Right - itemMargin; + + c.DrawText(fontSource.Glyph, itemLeft, itemBottom, paint); + } + else + { + _background.SetBounds((int)(itemView.Left + dX), itemView.Top, itemView.Left, itemView.Bottom); + _background.Draw(c); + + int itemLeft = itemView.Left + itemMargin; + int itemRight = itemView.Left + itemMargin + width; + + c.DrawText(fontSource.Glyph, itemLeft, itemBottom, paint); + } + + base.OnChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); + } + + private Paint GetIconPaint(View itemView, FontImageSource fontSource) + { + var paint = new Paint + { + TextSize = TypedValue.ApplyDimension(ComplexUnitType.Dip, (float)fontSource.Size, _context.Resources.DisplayMetrics), + Color = fontSource.Color.ToAndroid(), + TextAlign = Paint.Align.Left, + AntiAlias = true, + }; + + if (fontSource.FontFamily != null) + { + if (!_fontFamilyTypefaceCache.TryGetValue(fontSource.FontFamily, out var typeface)) + { + var font = new Xamarin.Forms.Font(); + // HACK: there is no way to set the font family of Font + // and the only public extension method to get the typeface is thorugh a Xamarin.Forms.Font + // so we use reflection here to set the font family and take advantage of ToTypeface method + // Also, we need to box the font in order to use reflection to set the property because it's a struct + object fontBoxed = font; + var pinfo = typeof(Xamarin.Forms.Font) + .GetProperty(nameof(Xamarin.Forms.Font.FontFamily)); + pinfo.SetValue(fontBoxed, fontSource.FontFamily, null); + typeface = ((Xamarin.Forms.Font)fontBoxed).ToTypeface(); + _fontFamilyTypefaceCache.Add(fontSource.FontFamily, typeface); + } + + paint.SetTypeface(typeface); + } + + int alpha = Math.Abs(((int)((itemView.TranslationX / itemView.Width) * 510))); + paint.Alpha = Math.Min(alpha, 255); + + return paint; + } + + public override int GetSwipeDirs(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) + { + if (viewHolder is TemplatedItemViewHolder templatedViewHolder + && + _swipeableItem.CanSwipe(_viewHolderToTItem(viewHolder))) + { + return base.GetSwipeDirs(recyclerView, viewHolder); + } + return 0; + } + + private void ClearCanvas(Canvas c, float left, float top, float right, float bottom) + { + if (c != null) + c.DrawRect(left, top, right, bottom, _clearPaint); + } + + public override void OnSwiped(RecyclerView.ViewHolder viewHolder, int direction) + { + OnSwipedCommand?.Execute(viewHolder); + } + } +} diff --git a/src/App/Abstractions/ISwipeableItem.cs b/src/App/Abstractions/ISwipeableItem.cs new file mode 100644 index 000000000..0218330b0 --- /dev/null +++ b/src/App/Abstractions/ISwipeableItem.cs @@ -0,0 +1,11 @@ +using Xamarin.Forms; + +namespace Bit.App.Abstractions +{ + public interface ISwipeableItem + { + bool CanSwipe(TItem item); + FontImageSource GetSwipeIcon(TItem item); + Color GetBackgroundColor(TItem item); + } +} diff --git a/src/App/Controls/CipherViewCell/CipherViewModelSwipeableItem.cs b/src/App/Controls/CipherViewCell/CipherViewModelSwipeableItem.cs new file mode 100644 index 000000000..8c8b99300 --- /dev/null +++ b/src/App/Controls/CipherViewCell/CipherViewModelSwipeableItem.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using Bit.App.Abstractions; +using Bit.App.Utilities; +using Bit.Core; +using Bit.Core.Enums; +using Xamarin.Forms; + +namespace Bit.App.Controls +{ + public class CipherViewModelSwipeableItem : ISwipeableItem + { + readonly Dictionary _imageCache = new Dictionary(); + + public bool CanSwipe(CipherViewCellViewModel item) + { + if (item?.Cipher is null) + { + return false; + } + + return item.Cipher.Type == CipherType.Login + || + item.Cipher.Type == CipherType.Card + || + item.Cipher.Type == CipherType.SecureNote; + } + + public Xamarin.Forms.Color GetBackgroundColor(CipherViewCellViewModel item) + { + if (item?.Cipher is null) + { + return ThemeManager.GetResourceColor("PrimaryColor"); + } + + if (item.Cipher.Type == CipherType.Login + && + string.IsNullOrEmpty(item.Cipher.Login?.Password)) + { + return ThemeManager.GetResourceColor("SeparatorColor"); + } + + if (item.Cipher.Type == CipherType.Card + && + string.IsNullOrEmpty(item.Cipher.Card?.Number)) + { + return ThemeManager.GetResourceColor("SeparatorColor"); + } + + if (item.Cipher.Type == CipherType.SecureNote + && + string.IsNullOrEmpty(item.Cipher.Notes)) + { + return ThemeManager.GetResourceColor("SeparatorColor"); + } + + return ThemeManager.GetResourceColor("PrimaryColor"); + } + + public FontImageSource GetSwipeIcon(CipherViewCellViewModel item) + { + if (item?.Cipher is null) + { + return null; + } + + if (!_imageCache.TryGetValue(item.Cipher.Type, out var image)) + { + image = new IconFontImageSource { Color = ThemeManager.GetResourceColor("BackgroundColor") }; + switch (item.Cipher.Type) + { + case CipherType.Login: + image.Glyph = BitwardenIcons.Key; + break; + case CipherType.Card: + image.Glyph = BitwardenIcons.Hashtag; + break; + case CipherType.SecureNote: + image.Glyph = BitwardenIcons.Clone; + break; + default: + return null; + } + _imageCache.Add(item.Cipher.Type, image); + } + + return image; + } + } +} diff --git a/src/App/Controls/ExtendedCollectionView.cs b/src/App/Controls/ExtendedCollectionView.cs index 79e1bac9d..819d94153 100644 --- a/src/App/Controls/ExtendedCollectionView.cs +++ b/src/App/Controls/ExtendedCollectionView.cs @@ -1,9 +1,19 @@ -using Xamarin.Forms; +using System.Windows.Input; +using Xamarin.Forms; namespace Bit.App.Controls { public class ExtendedCollectionView : CollectionView { + public static BindableProperty OnSwipeCommandProperty = + BindableProperty.Create(nameof(OnSwipeCommand), typeof(ICommand), typeof(ExtendedCollectionView)); + + public ICommand OnSwipeCommand + { + get => (ICommand)GetValue(OnSwipeCommandProperty); + set => SetValue(OnSwipeCommandProperty, value); + } + public string ExtraDataForLogging { get; set; } } } diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml b/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml index c62bce60d..ea9a6cdfc 100644 --- a/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml +++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPage.xaml @@ -189,7 +189,8 @@ SelectionMode="Single" SelectionChanged="RowSelected" StyleClass="list, list-platform" - ExtraDataForLogging="Groupings Page" /> + ExtraDataForLogging="Groupings Page" + OnSwipeCommand="{Binding SwipeItemActionCommand}"/> diff --git a/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItemSelector.cs b/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItemSelector.cs index 19a2379b2..7da590bbb 100644 --- a/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItemSelector.cs +++ b/src/App/Pages/Vault/GroupingsPage/GroupingsPageListItemSelector.cs @@ -29,9 +29,11 @@ namespace Bit.App.Pages return GroupTemplate; } - return listItem.Cipher.Type != Core.Enums.CipherType.Identity && SwipeableCipherTemplate != null - ? SwipeableCipherTemplate - : CipherTemplate; + return CipherTemplate; + + //return listItem.Cipher.Type != Core.Enums.CipherType.Identity && SwipeableCipherTemplate != null + // ? SwipeableCipherTemplate + // : CipherTemplate; } return null;