Search code examples
objective-ccocoa-touchcore-graphics

How can I draw a line with CoreGraphics, whose trail will begin to disappear at a certain length?


What I'm talking about can be seen here, from starting at 1:04. After 5-10 seconds, you'll understand what I mean by "trail will begin to disappear".

My current app draws a faint and thin line was the user moves around the screen. However, that lines sticks around until -touchesEnded:withEvent:, when I set the imageView.image = nil.

What I want to achieve is a line that is actively being drawn, and as you draw the line, the oldest parts of the line will become more transparent until it eventually disappears. The line draw could either be time based, or it could be based on how long the lines length currently is.

How could I achieve this?


Solution

  • I don't know how you're currently doing this, but this is how I'd go about doing it...

    1. Create a custom object to store a small portion of the trail, as well as an alpha and delay.
    2. In touchesMoved: calculate change in the position of the user touch and generate a new subpath based on that, then wrap it in the custom object.

    3. Draw all the subpaths in the -drawRect: method, with their given alphas.

    4. Setup a CADisplayLink to update the alphas and delays of the subpaths.

    So first of all, let's define our custom object...

    /// Represents a small portion of a trail. 
    @interface trailSubPath : NSObject
    
    /// The subpath of the trail.
    @property (nonatomic) CGPathRef path;
    
    /// The alpha of this section.
    @property (nonatomic) CGFloat alpha;
    
    /// The delay before the subpath fades
    @property (nonatomic) CGFloat delay;
    
    @end
    

    Let's also give it a convenience initialiser to make it look slick later on...

    @implementation trailSubPath
    
    +(instancetype) subPathWithPath:(CGPathRef)path alpha:(CGFloat)alpha delay:(CGFloat)delay {
        trailSubPath* subpath = [[self alloc] init];
        subpath.path = path;
        subpath.alpha = alpha;
        subpath.delay = delay;
        return subpath;
    }
    
    @end
    

    Let's also define some constants at the top of your UIView (make a subclass if you haven't already, as we're going to be drawing with -drawRect:)

    /// How long before a subpath starts to fade.
    static CGFloat const pathFadeDelay = 5.0;
    
    /// How long the fading of the subpath goes on for.
    static CGFloat const pathFadeDuration = 1.0;
    
    /// The stroke width of the path.
    static CGFloat const pathStrokeWidth = 3.0;
    

    In your UIView, you are going to want to store an NSMutableArray of your trailSubPath objects, as well as some other variables that we'll need later.

    I decided the use a CADisplayLink to handle the updates to the trailSubPath objects. This way, the code will run at the same speed on all devices (at the cost of a lower FPS on slower devices).

    @implementation view {
    
        UIColor* trailColor; // The stroke color of the trail
        NSMutableArray* trailSubPaths; // The array of trailSubPaths
    
        CGPoint lastPoint; // Last point the user touched
        BOOL touchedDown; // Whether the user is touching the screen
    
        CADisplayLink* displayLink; // A display link in order to allow the code to run at the same speed on different devices
    }
    

    In the -initWithFrame: method, we're going to do some basic setup...

    -(instancetype) initWithFrame:(CGRect)frame {
        if (self = [super initWithFrame:frame]) {
    
            trailSubPaths = [NSMutableArray array];
            trailColor = [UIColor redColor];
    
            self.backgroundColor = [UIColor whiteColor];
        }
        return self;
    }
    

    Now let's setup the UIResponder touch methods...

    -(void) touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        lastPoint = [[[event allTouches] anyObject] locationInView:self];
        touchedDown = YES;
    
        [displayLink invalidate]; // In case it's already running.
        displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkDidFire)];
        [displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
    }
    
    -(void) touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        if (touchedDown) {
    
            CGPoint p = [[[event allTouches] anyObject] locationInView:self];
    
            CGMutablePathRef mutablePath = CGPathCreateMutable(); // Create a new subpath
            CGPathMoveToPoint(mutablePath, nil, lastPoint.x, lastPoint.y);
            CGPathAddLineToPoint(mutablePath, nil, p.x, p.y);
    
            // Create new subpath object
            [trailSubPaths addObject:[trailSubPath subPathWithPath:CGPathCreateCopy(mutablePath) alpha:1.0 delay:pathFadeDelay]];
    
            CGPathRelease(mutablePath);
    
            lastPoint = p;
        }
    }
    
    -(void) touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        touchedDown = NO;
    }
    
    -(void) touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        [self touchesEnded:touches withEvent:event];
    }
    

    Nothing too complicated there, it just calculates the change in position of the touch on -touchesMoved: and generates a new straight line subpath based on this. This is then wrapped in our trailSubPath and added to the array.

    Now, we need to setup the logic in the CADisplayLink update method. This will just calculate the change in alphas and delays of the subpaths and remove any subpaths that have already faded out:

    -(void) displayLinkDidFire {
    
        // Calculate change in alphas and delays.
        CGFloat deltaAlpha = displayLink.duration/pathFadeDuration;
        CGFloat deltaDelay = displayLink.duration;
    
        NSMutableArray* subpathsToRemove = [NSMutableArray array];
    
        for (trailSubPath* subpath in trailSubPaths) {
    
            if (subpath.delay > 0) subpath.delay -= deltaDelay;
            else subpath.alpha -= deltaAlpha;
    
            if (subpath.alpha < 0) { // Remove subpath
                [subpathsToRemove addObject:subpath];
                CGPathRelease(subpath.path);
            }
        }
    
        [trailSubPaths removeObjectsInArray:subpathsToRemove];
    
        // Cancel running if nothing else to do.
        if (([trailSubPaths count] == 0) && !touchedDown) [displayLink invalidate];
        else [self setNeedsDisplay];
    }
    

    Then finally, we just want to override the drawRect: method in order to draw all our trailSubPath objects in Core Graphics:

    - (void)drawRect:(CGRect)rect {
    
        CGContextRef ctx = UIGraphicsGetCurrentContext();
        CGContextSetStrokeColorWithColor(ctx, trailColor.CGColor);
        CGContextSetLineWidth(ctx, pathStrokeWidth);
    
        for (trailSubPath* subpath in trailSubPaths) {
            CGContextAddPath(ctx, subpath.path);
            CGContextSetAlpha(ctx, subpath.alpha);
            CGContextStrokePath(ctx);
        }
    
    }
    

    It looks like a lot of code, but I'm sure you already have half of it setup to draw your line at the moment!

    Just note that a simple way to make the trial fade based on length is to move the call to setNeedsDisplay in the CADisplayLink update method over to the -touchesMoved: method, and invalidate the display link on -touchesEnded:.

    Phew. It's over... longest answer I've ever done.


    Finished Result

    Le finished result

    Full project: https://github.com/hamishknight/Fading-Trail-Path