Sten Petrov
Sten Petrov

Reputation: 11040

Drag an irregular shape in Xamarin.Forms

I have a Xamarin.Forms app where I need to drag irregularly shaped controls (TwinTechForms SvgImageView) around, like this one:

enter image description here

I want it to only respond to touches on the black area and not on transparent (checkered) areas

I tried using MR.Gestures package. Hooking up to the Panning event lets me drag the image but it also starts dragging when I touch the transparent parts of it.

My setup looks like this:

<mr:ContentView x:Name="mrContentView" Panning="PanningEventHandler" Panned="PannedEventHandler" Background="transparent">
  <ttf:SvgImageView x:Name="svgView" Background="transparent" SvgPath=.../>
</mr:ContentView>

and code-behind

private void PanningEventHandler(object sender, PanningEventParameters arg){  
     svgView.TranslateX = arg.IsCancelled ? 0: arg.TotalDistance.X;
     svgView.TranslateY = arg.IsCancelled ? 0: arg.TotalDistance.Y; 
}

private void PannedEventHandler(object sender, PanningEventParameters arg){  
  if (!arg.IsCancelled){
     mrContentView.TranslateX = svgView.TranslateX;
     mrContentView.TranslateY = svgView.TranslateY; 
  }
  svgView.TranslateX = 0;
  svgView.TranslateY = 0;
}

In this code-behind how should I check if I'm hitting a transparent point on the target object and when that happens how do I cancel the gesture so that another view under this one may respond to it? In the right side image touching the red inside the green O's hole should start dragging the red O

Update: SOLVED

The accepted answer's suggestion worked but was not straightforward.

I had to fork and modify both NGraphics (github fork) and TwinTechsFormsLib (TTFL, github fork)

In the NGraphics fork I added an XDocument+filter ctor to SvgReader so the same XDocument can be passed into different SvgImageView instances with a different parse filter, effectively splitting up the original SVG into multiple SvgImageView objects that can be moved independently without too much of a memory hit. I had to fix some brush inheritance for my SVGs to show as expected.

The TTFL fork exposes the XDocument+filter ctor and adds platform-specific GetPixelColor to the renderers.

Then in my Xamarin.Forms page I can load the original SVG file into multiple SvgImageView instances:

List<SvgImageView> LoadSvgImages(string resourceName, int widthRequest = 500, int heightRequest = 500)
{
    var svgImageViews = new List<SvgImageView>();

    var assembly = this.GetType().GetTypeInfo().Assembly;
    Stream stream = assembly.GetManifestResourceStream(resourceName);
    XDocument xdoc = XDocument.Load(stream);

    // only groups that don't have other groups
    List<XElement> leafGroups = xdoc.Descendants()
        .Where(x => x.Name.LocalName == "g" && x.HasElements && !x.Elements().Any(dx => dx.Name.LocalName == "g"))
        .ToList();

    leafGroups.Insert(0, new XElement("nonGroups")); // this one will 
    foreach (XElement leafGroup in leafGroups)
    { 
        var svgImage = new SvgImageView
        {
            HeightRequest = widthRequest,
            WidthRequest = heightRequest,
            HorizontalOptions = LayoutOptions.Start,
            VerticalOptions = LayoutOptions.End,
            StyleId = leafGroup.Attribute("id")?.Value, // for debugging
        };

        // this loads the original SVG as if only there's only one leaf group
        // and its parent groups (to preserve transformations, brushes, opacity etc)
        svgImage.LoadSvgFromXDocument(xdoc, (xe) =>
        {
            bool doRender = xe == leafGroup ||
                            xe.Ancestors().Contains(leafGroup) ||
                            xe.Descendants().Contains(leafGroup); 
            return doRender;
        });

        svgImageViews.Add(svgImage);
    }

    return svgImageViews;
}

Then I add all of the svgImageViews to a MR.Gesture <mr:Grid x:Name="movableHost"> and hook up Panning and Panned events to it.

SvgImageView dragSvgView = null; Point originalPosition = Point.Zero; movableView.Panning += (sender, pcp) => { // if we're not dragging anything - check the previously loaded SVG images // if they have a non-transparent pixel at the touch point if (dragSvgView==null){ dragSvgView = svgImages.FirstOrDefault(si => { var c = si.GetPixelColor(pcp.Touches[0].X - si.TranslationX, pcp.Touches[0].Y - si.TranslationY); return c.A > 0.0001; });

    if (dragSvgView != null)
    {
      // save the original position of this item so we can put it back in case dragging was canceled
      originalPosition = new Point (dragSvgView.TranslationX, dragSvgView.TranslationY);  
    }
  }
  // if we're dragging something - move it along
  if (dragSvgView != null)
  {
    dragSvgView.TranslationX += pcp.DeltaDistance.X;
    dragSvgView.TranslationY += pcp.DeltaDistance.Y;
  }

}

Upvotes: 2

Views: 310

Answers (1)

Michael Rumpler
Michael Rumpler

Reputation: 335

Neither MR.Gestures nor any underlying platform checks if a touched area within the view is transparent. The elements which listen to the touch gestures are always rectangular. So you have to do the hit testing yourself.

The PanningEventParameters contain a Point[] Touches with the coordinates of all touching fingers. With these coordinates you can check if they match any visible area in the SVG.

Hit-testing for the donut from your sample is easy, but testing for a general shape is not (and I think that's what you want). If you're lucky, then SvgImage already supports it. If not, then you may find the principles how this can be done in the SVG Rendering Engine, Point-In-Polygon Algorithm — Determining Whether A Point Is Inside A Complex Polygon or 2D collision detection.

Unfortunately overlapping elements are a bit of a problem. I tried to implement that with the Handled flag when I originally wrote MR.Gestures, but I couldn't get it to work on all platforms. As I thought it's more important to be consistent than to make it work on just one platform, I ignore Handled on all platforms and rather raise the events for all overlapping elements. (I should've removed the flag altogether)

In your specific case I'd propose you use a structure like this for multiple SVGs:

<mr:ContentView x:Name="mrContentView" Panning="PanningEventHandler" Panned="PannedEventHandler" Background="transparent">
  <ttf:SvgImageView x:Name="svgView1" Background="transparent" SvgPath=.../>
  <ttf:SvgImageView x:Name="svgView2" Background="transparent" SvgPath=.../>
  <ttf:SvgImageView x:Name="svgView3" Background="transparent" SvgPath=.../>
</mr:ContentView>

In the PanningEventHandler you can check if the Touches are on any SVG and if yes, which one is on top.

If you'd do multiple ContentViews each with only one SVG in it, then the PanningEventHandler would be called for each overlapping rectangular element which is not what you want.

Upvotes: 2

Related Questions