CATransform3D.MakeScale is moving layer - ios

I am developing a Xamarin.Forms application for iOS. This app consists of an UIView, which has sublayers which are CALayers. They are added like this:
// draw all the pins from the list
foreach (var pin in _control.PinsSource)
{
var point = new CGPoint
{
X = pin.Longitude,
Y = pin.Latitude
};
var shapeLayer = new CAShapeLayer
{
Name = nameof(MapItem),
Path = MakeCircleAtLocation(point, PinRadius).CGPath,
FillColor = UIColor.Red.CGColor
};
Layer.AddSublayer(shapeLayer);
}
// Create a UIBezierPath which is a circle at a certain location of a certain radius.
private static UIBezierPath MakeCircleAtLocation(CGPoint location, nfloat radius)
{
var path = new UIBezierPath();
path.AddArc(location, radius, 0, (float)(Math.PI * 2.0), true);
return path;
}
Then I have a UIPinchGestureRecognizer which can scale the UIView and some other GestureRecognizers like panning.
Scaling and panning the base view works well. The UIView is scaled using a variable called _currentScale. See the full scale method here:
private void HandlePinch(UIPinchGestureRecognizer recognizer)
{
// Prevent the object to become too large or too small
var newScale = (nfloat)Math.Max(MinZoomLevel, Math.Min(_currentScale * recognizer.Scale, MaxZoomLevel));
if (_currentScale != newScale)
{
_currentScale = newScale;
Transform = CGAffineTransform.MakeScale(_currentScale, _currentScale);
foreach (var subLayer in Layer.Sublayers)
{
if (subLayer.Name == nameof(MapItem))
subLayer.Transform = CATransform3D.MakeScale(PinRadius / _currentScale, PinRadius / _currentScale, 1);
}
}
recognizer.Scale = 1;
}
If the sublayer is a map pin, I did like to NOT scale it with the _currentScale, so that's why I am dividing the scale using PinRadius / _currentScale.
The scaling is working fine, however the pin is moving across the map which is weird. See here:
How can I resolve this?

Unfortunately I couldn't find another way then recreating a new CAShapeLayer on every pinch. It's very ugly, but it's working.
private void HandlePinch(UIPinchGestureRecognizer recognizer)
{
// Prevent the object to become too large or too small
var newScale = (nfloat)Math.Max(MinZoomLevel, Math.Min(_currentScale * recognizer.Scale, _maxZoomLevel));
if (_currentScale != newScale)
{
_currentScale = newScale;
_currentPinRadius = _pinRadius / _currentScale;
Transform = CGAffineTransform.MakeScale(_currentScale, _currentScale);
// First layer is a CALayer, so start at 1
for (var i = 1; i < Layer.Sublayers.Length; i++)
{
var caLayer = ((CAShapeLayer)Layer.Sublayers[i]);
var cgPoint = new CGPoint
{
X = _control.PinsSource[i - 1].Longitude,
Y = _control.PinsSource[i - 1].Latitude
};
caLayer.Path = CreateCircle(cgPoint, _currentPinRadius);
}
}
recognizer.Scale = 1;
}

Related

Xamarin Pinch To Zoom and Pan Containers leaving their bounds

I have a label with a lot of text that I want to enable pinch-to-zoom and panning gesture recognizers in. I used the recipes from here and then nested them within each other.
https://developer.xamarin.com/guides/xamarin-forms/user-interface/gestures/pinch/
https://developer.xamarin.com/guides/xamarin-forms/user-interface/gestures/pan/
Problem is, both container objects allow you to move the label completely outside of it's normal bounds anywhere within the top level page view (demonstrated in the pictures below).
Any thoughts on how to implement some limits on these? I'm sure it's just placing some limits on the math in the container code, but I haven't found the right thing to change yet.
As you can see in these images, both the pinch-to-zoom container (without panning) and the pan container (without zooming) allow you to alter the control so it goes outside it's bounds.
Initial Layout:
Pinch-To-Zoom only
Panning only
Pinch and Pan
The links above have the container code, but here it is:
PinchToZoomContainer.cs
public class PinchToZoomContainer : ContentView
{
// Pinch Gesture variables
double currentScale = 1;
double startScale = 1;
double xOffset = 0;
double yOffset = 0;
public PinchToZoomContainer ()
{
var pinchGesture = new PinchGestureRecognizer ();
pinchGesture.PinchUpdated += OnPinchUpdated;
GestureRecognizers.Add (pinchGesture);
}
void OnPinchUpdated (object sender, PinchGestureUpdatedEventArgs e)
{
if (e.Status == GestureStatus.Started) {
// Store the current scale factor applied to the wrapped user interface element,
// and zero the components for the center point of the translate transform.
startScale = Content.Scale;
Content.AnchorX = 0;
Content.AnchorY = 0;
}
if (e.Status == GestureStatus.Running) {
// Calculate the scale factor to be applied.
currentScale += (e.Scale - 1) * startScale;
currentScale = Math.Max (1, currentScale);
// The ScaleOrigin is in relative coordinates to the wrapped user interface element,
// so get the X pixel coordinate.
double renderedX = Content.X + xOffset;
double deltaX = renderedX / Width;
double deltaWidth = Width / (Content.Width * startScale);
double originX = (e.ScaleOrigin.X - deltaX) * deltaWidth;
// The ScaleOrigin is in relative coordinates to the wrapped user interface element,
// so get the Y pixel coordinate.
double renderedY = Content.Y + yOffset;
double deltaY = renderedY / Height;
double deltaHeight = Height / (Content.Height * startScale);
double originY = (e.ScaleOrigin.Y - deltaY) * deltaHeight;
// Calculate the transformed element pixel coordinates.
double targetX = xOffset - (originX * Content.Width) * (currentScale - startScale);
double targetY = yOffset - (originY * Content.Height) * (currentScale - startScale);
// Apply translation based on the change in origin.
Content.TranslationX = targetX.Clamp (-Content.Width * (currentScale - 1), 0);
Content.TranslationY = targetY.Clamp (-Content.Height * (currentScale - 1), 0);
// Apply scale factor
Content.Scale = currentScale;
}
if (e.Status == GestureStatus.Completed) {
// Store the translation delta's of the wrapped user interface element.
xOffset = Content.TranslationX;
yOffset = Content.TranslationY;
}
}
PanContainer.cs
public class PanContainer : ContentView
{
double startX, startY;
double x, y;
public PanContainer ()
{
// Set PanGestureRecognizer.TouchPoints to control the
// number of touch points needed to pan
var panGesture = new PanGestureRecognizer ();
panGesture.PanUpdated += OnPanUpdated;
GestureRecognizers.Add (panGesture);
}
void OnPanUpdated (object sender, PanUpdatedEventArgs e)
{
switch (e.StatusType) {
case GestureStatus.Started:
startX = Content.TranslationX;
startY = Content.TranslationY;
break;
case GestureStatus.Running:
// Translate and ensure we don't pan beyond the wrapped user interface element bounds.
//Content.TranslationX = Math.Max (Math.Min (0, x + e.TotalX), -Math.Abs (Content.Width - App.ScreenWidth));// App.ScreenWidth));
//Content.TranslationY = Math.Max (Math.Min (0, y + e.TotalY), -Math.Abs (Content.Height - App.ScreenHeight)); //App.ScreenHeight));
Content.TranslationX = startX + e.TotalX;
Content.TranslationY = startY + e.TotalY;
break;
case GestureStatus.Completed:
// Store the translation applied during the pan
x = Content.TranslationX;
y = Content.TranslationY;
break;
}
}
}
I imagine, on the PanContainer, my issue is in these lines that I had to comment out:
//Content.TranslationX = Math.Max (Math.Min (0, x + e.TotalX), -Math.Abs (Content.Width - App.ScreenWidth));// App.ScreenWidth));
//Content.TranslationY = Math.Max (Math.Min (0, y + e.TotalY), -Math.Abs (Content.Height - App.ScreenHeight)); //App.ScreenHeight));
I changed these to a more simple version because I can't find App.ScreenWidth or .ScreenHeight properties.
The pinch container, however, is just as it was originally in the recipe and still goes outside the bounds.
There is an IsClippedToBounds property that helped me with this issue.
For example:
<PanContainer IsClippedToBounds="true">
<PanContainer.Content>
<Image x:Name="SomeImage" />
</PanContainer.Content>
</PanContainer>
To get pinch and pan, you can either wrap a pinch element in a pan element or vice versa, or you can create a single class with the functions from both the pinch and pan classes. The latter is probably better.
That alone will probably not work exactly as you expect though because the calculations in the pinch and pan functionality are not aware of each other, so if for example you pinch to zoom in then the pan functionality doesn't know that it can now pan further.
This answer is mostly likely very late for your needs, Chet... but, you can simply wrap the whole thing in a ScrollView (which you will appropriately locate and/or size to your needs). That should work as expected.
<ScrollView Grid.Column="2" VerticalOptions="Start">
<PanContainer>
<PanContainer.Content>
<Image x:Name="SomeImage" Aspect="AspectFit" />
</PanContainer.Content>
</PanContainer>
</ScrollView>
Cheers!
Mike

Move a UIImage inside of CGRect using animateWithDuration

So I'm trying to figure out how to move UIImages that are drawn inside a CGRect up a cell on the screen using animateWithDuration, but I'm having trouble visualizing where to write the code as well as how to write it for a UIImage. I have an array of CGRects with content the UIImage drawn inside of it, and I want to move all of the images at the same time. Once they get to the top most cell, I want them to then appear in the bottom cell and start again. Here is a picture to get a better idea of what I'm talking about:
DragMeToHell Screenshot
And here's my drawRect code for the UIView and UIImages:
override func drawRect(rect: CGRect) {
print( "drawRect:" )
let context = UIGraphicsGetCurrentContext()! // obtain graphics context
// CGContextScaleCTM( context, 0.5, 0.5 ) // shrink into upper left quadrant
let bounds = self.bounds // get view's location and size
let w = CGRectGetWidth( bounds ) // w = width of view (in points)
let h = CGRectGetHeight( bounds ) // h = height of view (in points)
self.dw = w/10.0 // dw = width of cell (in points)
self.dh = h/10.0 // dh = height of cell (in points)
print( "view (width,height) = (\(w),\(h))" )
print( "cell (width,height) = (\(self.dw),\(self.dh))" )
// draw lines to form a 10x10 cell grid
CGContextBeginPath( context ) // begin collecting drawing operations
for i in 1..<10 {
// draw horizontal grid line
let iF = CGFloat(i)
CGContextMoveToPoint( context, 0, iF*(self.dh) )
CGContextAddLineToPoint( context, w, iF*self.dh )
}
for i in 1..<10 {
// draw vertical grid line
let iFlt = CGFloat(i)
CGContextMoveToPoint( context, iFlt*self.dw, 0 )
CGContextAddLineToPoint( context, iFlt*self.dw, h )
}
UIColor.grayColor().setStroke() // use gray as stroke color
CGContextDrawPath( context, CGPathDrawingMode.Stroke ) // execute collected drawing ops
// establish bounding box for image
let tl = self.inMotion ? CGPointMake( self.x, self.y )
: CGPointMake( CGFloat(row)*self.dw, CGFloat(col)*self.dh )
let imageRect = CGRectMake(tl.x, tl.y, self.dw, self.dh)
// place images in random cells
//cellCoordinates = self.generateCoordinates()
for xy in cellCoordinates {
let randomImageRect = CGRectMake(xy.x, xy.y, self.dw, self.dh)
let lavaImage : UIImage? = UIImage(named: "lava.png")
lavaImage!.drawInRect(randomImageRect)
imageCells.append(randomImageRect)
}
// place appropriate image where dragging stopped [EDITED]
var img : UIImage?
if ( self.col == 9 ) {
img = UIImage(named:"otto.png")
} else {
img = UIImage(named:"angel.png")
}
img!.drawInRect(imageRect)
// check for image intersection
for tempImageRect in imageCells {
if (CGRectIntersectsRect(imageRect, tempImageRect)) {
img = UIImage(named:"devil.png")
img!.drawInRect(imageRect)
self.backgroundColor = UIColor.redColor()
}
}
}

CALayer drawinContext called # 60fps but view update graphics # 1fps

I am trying to implement a graph drawing view in OSX using Cocoa and Quartz framework using NSBezierPath and add/delete data points as I go.
Doing so in drawRect worked fine as the graph was updating frequently but then I encountered performance problem when I need to increase total datapoints/sampling rate.
I decided to move to drawLayer: inContext: but as the function is called at 60fps, the view isn't updating the graph when the function is call and instead update at 1fps.
What am I doing wrong here?
class CustomDrawLayer: CALayer {
convenience init(view: NSView, drawsAsynchronously : Bool = false) {
self.init()
self.bounds = view.bounds
self.anchorPoint = CGPointZero
self.opaque = false
self.frame = view.frame
self.drawsAsynchronously = drawsAsynchronously
// for multiple draws in hosting view
// self.delegate = self
}
override func actionForLayer(layer: CALayer, forKey event: String) -> CAAction? {
return nil
}}
override func drawLayer(layer: CALayer, inContext ctx: CGContext) {
if layer == self.layer {
Swift.print("axes drawing")
graphBounds.origin = self.frame.origin
graphAxes.drawAxesInRect(graphBounds, axeOrigin: plotOrigin, xPointsToShow: CGFloat(totalSecondsToDisplay), yPointsToShow: CGFloat(totalChannelsToDisplay))
}
if layer == self.board {
Swift.print(1/NSDate().timeIntervalSinceDate(fpsTimer))
fpsTimer = NSDate()
drawPointsInGraph(graphAxes, context: ctx)
}
}
func drawPointsInGraph(axes: AxesDrawer, context: CGContext)
{
color.set()
var x : CGFloat = 0
var y : CGFloat = 0
for var channel = 0; channel < Int(totalChannelsToDisplay); channel++ {
path.removeAllPoints()
var visibleIndex = (dirtyRect.origin.x - axes.position.x) / (axes.pointsPerUnit.x / samplingRate)
if visibleIndex < 2 {
visibleIndex = 2
}
for var counter = Int(visibleIndex); counter < dataStream![channel].count; counter++ {
if dataStream![channel][counter] == 0 {
if path.elementCount > 0 {
path.stroke()
}
break
}
let position = axes.position
let ppY = axes.pointsPerUnit.y
let ppX = axes.pointsPerUnit.x
let channelYLocation = CGFloat(channel)
x = position.x + CGFloat(counter-1) * (ppX / samplingRate)
y = ((channelYLocation * ppY) + position.y) + (dataStream![channel][counter-1] * (ppY))
path.moveToPoint(CGPoint(x: align(x), y: align(y)))
x = position.x + CGFloat(counter) * (ppX / samplingRate)
y = ((channelYLocation * ppY) + position.y) + (dataStream![channel][counter] * (ppY) )
path.lineToPoint(CGPoint(x: align(x), y: align(y)))
if x > (axes.position.x + axes.bounds.width) * 0.9 {
graphAxes.forwardStep = 5
dirtyRect = graphBounds
for var c = 0; c < Int(totalChannelsToDisplay); c++ {
for var i = 0; i < Int(samplingRate) * graphAxes.forwardStep; i++
{
dataStream![c][i] = 0
}
}
return
}
}
path.stroke()
}
if inLiveResize {
dirtyRect = graphBounds
} else {
dirtyRect.origin.x = x
dirtyRect.origin.y = bounds.minY
dirtyRect.size.width = 10
dirtyRect.size.height = bounds.height
}
}
It is incredibly rare that you should ever call a function at 60 Hz. In no case should you ever try to call a drawing function at 60 Hz; that never makes sense in Cocoa. If you really mean "at the screen refresh interval," see CADisplayLink, which is specifically built to allow you to draw at the screen refresh interval. This may be slower than 60 Hz. If you try to draw exactly at 60 Hz, you can get out of sync and cause beats in your animation. But this really only intended for things like real-time video. If that what you have, then this is the tool, but it doesn't really sound like it.
It's a bit difficult to understand your code. It's not clear where your 60fps comes in. But I'm assuming what you're trying to do is animate drawing the graph. If so, as Mark F notes, see CAShapeLayer. It has automatic path animations built-in, and is definitely what you want. It automatically handles timings and syncing with the screen refresh and GPU optimizations, and lots of other things that you shouldn't try to work around.
Even if CAShapeLayer isn't what you want, you should be looking at Core Animation, which is designed to work with you to animate values and redraw as necessary. It automatically will handle rendering your layer on multiple cores for instance, which will dramatically improve performance. For more on that, see Animating Custom Layer Properties.
If your path needs to be drawn that frequently, check out CAShapeLayer, where you can just change the path property. That will be hardware accelerated and much faster than drawRect or drawLayer.

Getting object at location on screen in Metal

I'm using Metal and I want the user to be able to tap certain objects onscreen. I'm using a tap gesture recognizer, and so I have a CGPoint, but I don't know how to find the object at that point. Is there either a way to get the onscreen coordinates for metal, or a way to transform the CGPoint into the metal coordinate space?
I ended up doing this with a raytracing-type technique. I used a bounding sphere for each object and calculated whether or not the ray intersected it and how far from the ray's origin the intersection was, with the ray being from the camera through the onscreen point of clicking. The code is as follows:
var location = gestureRecognizer.locationInView(self.view)
var proj_matrix = projectionMatrix.copy()
var view_matrix = worldMatrix.copy()
var x = (2.0 * location.x) / self.view.bounds.size.width - 1.0;
var y = 1.0 - (2.0 * location.y) / self.view.bounds.size.height;
var newLocation = simpleVertex(x: Float(x), y: Float(y), z: Float(-1.0))
var projSpaceLoc = GLKMatrix4MultiplyVector4(GLKMatrix4Invert(proj_matrix.matrix(), nil), GLKVector4Make(newLocation.x, newLocation.y, newLocation.z, 1.0))
projSpaceLoc = GLKVector4Make(projSpaceLoc.x, projSpaceLoc.y, -1.0, 0.0)
var viewSpaceLoc = GLKMatrix4MultiplyVector4(GLKMatrix4Invert(view_matrix.matrix(), nil), projSpaceLoc)
GLKVector4Normalize(viewSpaceLoc)
//tappable is an array of nodes that can be tapped
for node in tappable {
var r: Float = node.r //this is the radius of the bounding sphere for that node
var c: GLKVector4 = node.origin //this is the origin of the bounding sphere
var little_c = GLKVector4DotProduct(c, c) - powf(r, 2)
var b = GLKVector4DotProduct(viewSpaceLoc, c)
var discriminant = powf(b, 2) - little_c
//If the discriminant is positive, their are two points of intersection, if it is 0, one point, and if it is negative, no points.
if (discriminant >= 0) {
var t_1 = (Float(-1.0)*b) + sqrtf(discriminant)
var t_2 = (Float(-1.0)*b) - sqrtf(discriminant)
if (intersect != nil) {
if (min(t_1, t_2) < intersect.intersect_point) {
intersect = (node, min(t_1, t_2))
}
} else {
intersect = (node, min(t_1, t_2))
}
}
}

Update the rotation of a CALayer

I am trying to update the current rotation (and sometimes the position) of a CALayer.
What I am trying to in a couple of simple steps:
Store a couple of CALayers in an array, so I can reuse them
Set the anchor point of all CALayers to 0,0.
Draw CALayer objects where the object starts at a position on a circle
The layers are rotated by the same angle as the circle at that position
Update the position and rotation of the CALayer to match new values
Here is a piece of code I have:
lineWidth is the width of a line
self.items is an array containing the CALayer objects
func updateLines() {
var space = 2 * M_PI * Double(circleRadius);
var spaceAvailable = space / (lineWidth)
var visibleItems = [Int]();
var startIndex = items.count - Int(spaceAvailable);
if (startIndex < 0) {
startIndex = 0;
}
for (var i = startIndex; i < self.items.count; i++) {
visibleItems.append(self.items[i]);
}
var circleCenter = CGPointMake(CGRectGetMidX(self.frame), CGRectGetMidY(self.frame));
/* Each line should move up and rotate accordin to this value */
var anglePerLine: CGFloat = (360 / CGFloat(visibleItems.count)).toRadians()
/* Starting position, 270 degrees is on top */
var startAngle: CGFloat = CGFloat(270).toRadians();
/* Lines default rotation, we rotate it to get the right side up */
var lineAngle: CGFloat = CGFloat(180).toRadians();
for (var itemIndex = 0; itemIndex < visibleItems.count; itemIndex++) {
var itemLayer = self.itemLayers[itemIndex];
itemLayer.opacity = 1 - ((0.9 / visibleItems.count) * itemIndex);
/* Calculate start position of layer */
var x = CGFloat(circleRadius) * cos(startAngle) + CGFloat(circleCenter.x);
var y = CGFloat(circleRadius) * sin(startAngle) + CGFloat(circleCenter.y);
var height = CGFloat((arc4random() % 80) + 10);
/* Set position and frame of layer */
itemLayer.frame = CGRectMake(CGFloat(x), CGFloat(y), CGFloat(lineWidth), height);
itemLayer.position = CGPointMake(CGFloat(x), CGFloat(y));
var currentRotation = CGFloat((itemLayer.valueForKeyPath("transform.rotation.z") as NSNumber).floatValue);
var newRotation = lineAngle - currentRotation;
var rotationTransform = CATransform3DRotate(itemLayer.transform, CGFloat(newRotation), 0, 0, 1);
itemLayer.transform = rotationTransform;
lineAngle += anglePerLine;
startAngle += anglePerLine;
}
}
The result of the first run is exactly as I want it to be:
The second run through this code just doesn't update the CALayers correctly and it starts to look like this:
I think it has to do with my code to update the location and transform properties of the CALayer, but whatever I do, it always results in the last picture.
Answered via Twitter: setting frames and transform is mutually exclusive. Happy to help. Finding my login credentials for SO is harder. :D
Found the answer thanks to #iosengineer on Twitter. When setting a position on the CALayer, you do not want to update the frame of the layer, but you want to update the bounds.
Smooth animation FTW

Resources