Skip to content

Instantly share code, notes, and snippets.

@bbenetskyy
Created December 31, 2021 09:38
Show Gist options
  • Save bbenetskyy/b271666a6824f8dfbf299d3badf4e9f5 to your computer and use it in GitHub Desktop.
Save bbenetskyy/b271666a6824f8dfbf299d3badf4e9f5 to your computer and use it in GitHub Desktop.
public class DraggableView : ContentView, IDisposable
{
public static readonly BindableProperty NewXProperty = BindableProperty.Create(
propertyName: nameof(NewX),
returnType: typeof(double),
declaringType: typeof(DraggableView));
public double NewX
{
get => (double)GetValue(NewXProperty);
set => SetValue(NewXProperty, value);
}
public static readonly BindableProperty NewYProperty = BindableProperty.Create(
propertyName: nameof(NewY),
returnType: typeof(double),
declaringType: typeof(DraggableView));
public double NewY
{
get => (double)GetValue(NewYProperty);
set => SetValue(NewYProperty, value);
}
public static readonly BindableProperty CommandProperty = BindableProperty.Create(
nameof(Command),
typeof(ICommand),
typeof(DraggableView));
public ICommand Command
{
get => (ICommand)GetValue(CommandProperty);
set => SetValue(CommandProperty, value);
}
public static readonly BindableProperty CommandParamProperty = BindableProperty.Create(
nameof(CommandParam),
typeof(object),
typeof(DraggableView));
public object CommandParam
{
get => GetValue(CommandParamProperty);
set => SetValue(CommandParamProperty, value);
}
public DraggableArea LimitArea { get; set; }
protected override void OnParentSet()
{
base.OnParentSet();
if (Parent is View parent)
{
parent.SizeChanged += Parent_SizeChanged;
}
}
private void Parent_SizeChanged(object sender, EventArgs e)
{
if (sender is View {Height: > 0, Width: > 0} parent)
{
LimitArea = new DraggableArea(parent.X, parent.Y,
parent.Width,
parent.Height);
}
}
public void Dispose()
{
if (Parent is View parent)
{
parent.SizeChanged -= Parent_SizeChanged;
}
}
}
public class DraggableArea
{
public DraggableArea(double x,
double y,
double width,
double height)
{
X = x;
Y = y;
Width = width;
Height = height;
}
public double X { get; }
public double Y { get; }
public double Width { get; }
public double Height { get; }
}
//Android
[assembly: ExportRenderer(typeof(DraggableView), typeof(DraggableViewRenderer))]
public class DraggableViewRenderer : VisualElementRenderer<Xamarin.Forms.View>
{
#region Fields
private CancellationTokenSource _throttleCts = new();
private float _deltaX;
private float _deltaY;
private bool _touchedDown;
private DraggableView _draggableView;
#endregion Fields
#region Constructors
public DraggableViewRenderer(Context context) : base(context)
{
_touchedDown = false;
}
#endregion Constructors
#region Protected Methods
protected override void OnElementChanged(ElementChangedEventArgs<Xamarin.Forms.View> e)
{
base.OnElementChanged(e);
if (e.OldElement != null)
{
LongClick -= HandleLongClick;
Click -= HandleOnClick;
_draggableView = null;
}
if (e.NewElement != null)
{
LongClick += HandleLongClick;
if(e.NewElement is DraggableView draggableView)
{
_draggableView = draggableView;
Click += HandleOnClick;
}
}
}
protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
{
base.OnElementPropertyChanged(sender, e);
if (e.PropertyName == Xamarin.Forms.VisualElement.XProperty.PropertyName || e.PropertyName == Xamarin.Forms.VisualElement.YProperty.PropertyName)
{
CancellationToken(() =>
{
var draggableView = Element as DraggableView;
if(draggableView is null)
return;
Element.TranslationX = draggableView.NewX;
Element.TranslationY = draggableView.NewY;
});
}
}
#endregion Protected Methods
#region Public Methods
public override bool OnTouchEvent(MotionEvent e)
{
var draggView = Element as DraggableView;
float x = e.RawX;
float y = e.RawY;
switch (e.Action)
{
case MotionEventActions.Down:
_deltaX = x - GetX();
_deltaY = y - GetY();
break;
case MotionEventActions.Move:
if (_touchedDown)
{
var newX = x - _deltaX;
var newY = y - _deltaY;
var limitArea = draggView.LimitArea;
var density = Xamarin.Essentials.DeviceDisplay.MainDisplayInfo.Density;
if (limitArea == null)
{
SetX(newX);
SetY(newY);
}
else
{
var width = Element.Width * density;
var height = Element.Height * density;
var limitWidth = limitArea.Width * density;
var limitHeight = limitArea.Height * density;
var limitX = limitArea.X * density;
var limitY = limitArea.Y * density;
var resX = newX;
var resY = newY;
if (Math.Max(limitX, resX) > limitX
&& Math.Min(limitX + limitWidth, resX + width) < limitX + limitWidth)
SetX(resX);
if (Math.Max(limitY, resY) > limitY
&& Math.Min(limitY + limitHeight, resY + height) < limitY + limitHeight)
SetY(newY);
}
draggView.NewX = newX / density;
draggView.NewY = newY / density;
}
break;
case MotionEventActions.Up:
_touchedDown = false;
break;
case MotionEventActions.Cancel:
_touchedDown = false;
break;
}
return base.OnTouchEvent(e);
}
#endregion Public Methods
#region Private Methods
private void HandleLongClick(object sender, LongClickEventArgs e)
{
_touchedDown = true;
}
private void HandleOnClick(object sender, EventArgs e)
{
_draggableView?.Command?.Execute(_draggableView?.CommandParam);
}
private void CancellationToken(Action action)
{
if (action != null)
{
Interlocked.Exchange(ref _throttleCts, new CancellationTokenSource()).Cancel();
Task.Delay(TimeSpan.FromMilliseconds(100), _throttleCts.Token) // throttle time
.ContinueWith(
delegate
{
action();
},
System.Threading.CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion,
TaskScheduler.FromCurrentSynchronizationContext());
}
}
#endregion Private Methods
}
//iOS
[assembly: ExportRenderer(typeof(DraggableView), typeof(DraggableViewRenderer))]
public class DraggableViewRenderer : VisualElementRenderer<View>
{
#region Fields
private CancellationTokenSource _throttleCts = new();
private bool _longPress;
private bool _firstTime = true;
private double _lastTimeStamp;
private double _translatedXPos;
private double _translatedYPos;
private UIPanGestureRecognizer _panGesture;
private UIGestureRecognizer.Token _panGestureToken;
private DraggableView _draggableView;
private UITapGestureRecognizer _tapGesture;
private UIGestureRecognizer.Token _tapGestureToken;
#endregion Fields
#region Public Methods
public override void TouchesBegan(NSSet touches, UIEvent evt)
{
base.TouchesBegan(touches, evt);
_lastTimeStamp = evt.Timestamp;
Superview.BringSubviewToFront(this);
}
public override void TouchesMoved(NSSet touches, UIEvent evt)
{
if (evt.Timestamp - _lastTimeStamp >= 0.5)
{
_longPress = true;
}
base.TouchesMoved(touches, evt);
}
#endregion Public Methods
#region Protected Methods
protected override void OnElementChanged(ElementChangedEventArgs<View> e)
{
base.OnElementChanged(e);
if (e.OldElement != null)
{
RemoveGestureRecognizer(_panGesture);
_panGesture.RemoveTarget(_panGestureToken);
RemoveGestureRecognizer(_tapGesture);
_tapGesture.RemoveTarget(_tapGestureToken);
_draggableView = null;
}
if (e.NewElement != null)
{
_draggableView = Element as DraggableView;
_panGesture = new UIPanGestureRecognizer();
_panGestureToken = _panGesture.AddTarget(DetectPan);
AddGestureRecognizer(_panGesture);
_tapGesture = new UITapGestureRecognizer();
_tapGestureToken = _tapGesture.AddTarget(DetectTap);
AddGestureRecognizer(_tapGesture);
}
}
protected override void OnElementPropertyChanged(object sender, PropertyChangedEventArgs e)
{
base.OnElementPropertyChanged(sender, e);
if (e.PropertyName == VisualElement.XProperty.PropertyName || e.PropertyName == VisualElement.YProperty.PropertyName)
{
CancellationToken(() =>
{
if (_draggableView is null)
return;
Element.TranslationX = _draggableView.NewX;
Element.TranslationY = _draggableView.NewY;
});
}
}
#endregion Protected Methods
#region Private Methods
private void DetectPan()
{
if (_longPress)
{
if (_panGesture.State == UIGestureRecognizerState.Began)
{
_panGesture.Reset();
if (_firstTime)
{
_firstTime = false;
}
// The Pan has just started, but we need the relative translation
// The view currently has applied
_translatedXPos = Element.TranslationX;
_translatedYPos = Element.TranslationY;
}
var translation = _panGesture.TranslationInView(Superview);
// Set the total translation based on the original state and the current translation
var dragView = Element as DraggableView;
var limitArea = dragView.LimitArea;
var translationX = _translatedXPos + translation.X;
var translationY = _translatedYPos + translation.Y;
if (limitArea == null)
{
Element.TranslationX = translationX;
Element.TranslationY = translationY;
}
else
{
var resX = Element.X + translationX;
var resY = Element.Y + translationY;
if (Math.Max(limitArea.X, resX) > limitArea.X
&& Math.Min(limitArea.X + limitArea.Width, resX + Element.Width) < limitArea.X + limitArea.Width)
Element.TranslationX = translationX;
if (Math.Max(limitArea.Y, resY) > limitArea.Y
&& Math.Min(limitArea.Y + limitArea.Height, resY + Element.Height) < limitArea.Y + limitArea.Height)
Element.TranslationY = translationY;
}
if (_panGesture.State == UIGestureRecognizerState.Ended)
{
_longPress = false;
var draggableView = Element as DraggableView;
if (draggableView is null)
return;
draggableView.NewX = Element.TranslationX;
draggableView.NewY = Element.TranslationY;
}
}
}
private void DetectTap()
{
if (!_longPress)
{
_draggableView?.Command?.Execute(_draggableView?.CommandParam);
}
}
private void CancellationToken(Action action)
{
if (action != null)
{
Interlocked.Exchange(ref _throttleCts, new CancellationTokenSource()).Cancel();
Task.Delay(TimeSpan.FromMilliseconds(100), _throttleCts.Token) // throttle time
.ContinueWith(
delegate
{
action();
},
System.Threading.CancellationToken.None,
TaskContinuationOptions.OnlyOnRanToCompletion,
TaskScheduler.FromCurrentSynchronizationContext());
}
}
#endregion Private Methods
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment