How to implement UIView that performs inverse of c

2019-07-19 18:09发布

The UIView contentStretch property defines an area inside of which content is stretched to fill the view. That's great and all, but I find myself in need of the exact opposite: I want a view where I can define a rect that does not stretch but have the outer edges stretched until they fill the view.

  +-------------------------------------------+
  |                                           |
  |         +----------+                      |
  |         |          |                      |
  |         |          |                      |
  |         |  Content |      Stretch to fill |
  |         |          |                      |
  |         |          |                      |
  |         +----------+                      |
  |                                           |
  +-------------------------------------------+

So in the above view, the outer rect is my proposed view. The inner rect is the non-stretchable content. Ideally I would like to be able to position the center point of the non-stretchable content anywhere within the outer rect and still have the outer bits fill to the edge.

Example usage scenario: a black overlay with a transparent center "hole" that follows the mouse / touch for exploring a scene with a flashlight, etc.

One way I thought to do this is to draw the content UIView, then draw four other views, size appropriately, to cover the outer area, but I was hoping for a single-view solution for smoother animating. I'm guessing I'll need to have a single UIView and use Core Animation to get down and mess with its Layers?

2条回答
forever°为你锁心
2楼-- · 2019-07-19 18:45

Presumably you wouldn't need four other views, you'd need eight. The inner “fixed” rectangle divides the view into nine slices:

+----+-----+---------+
|    |     |         |
+----+-----+---------|
|    |fixed|         |
|    |     |         |
+----+-----+---------+
|    |     |         |
|    |     |         |
|    |     |         |
+----+-----+---------+

Notice that each of the nine slices may require a different combination of horizontal and vertical scaling. It ends up looking like this:

outside stretching demo

Anyway, I thought it wouldn't be too hard to do this just by creating a UIView subclass that overrides drawRect:.

I named my subclass OutstretchView and gave it three properties:

@interface OutstretchView : UIView

@property (nonatomic) UIImage *image;
@property (nonatomic) CGRect fixedRect;  // in image coordinates
@property (nonatomic) CGPoint fixedCenter;  // in view coordinates

@end

The fixedRect property defines the part of the image that should never be stretched. The fixedCenter property defines where in the view that part of the image should be drawn.

To actually draw the nine slices of the image, it is helpful to define a method that takes a CGRect in image coordinates and a CGRect in view coordinates, and draws the specified part of the image in the specified part of the view. I use Quartz 2D's clip and CTM features to do the “heavy lifting”:

- (void)drawRect:(CGRect)imageRect ofImage:(UIImage *)image inRect:(CGRect)viewRect {
    CGContextRef gc = UIGraphicsGetCurrentContext();
    CGContextSaveGState(gc); {
        CGContextClipToRect(gc, viewRect);
        CGContextTranslateCTM(gc, viewRect.origin.x, viewRect.origin.y);
        CGContextScaleCTM(gc, viewRect.size.width / imageRect.size.width, viewRect.size.height / imageRect.size.height);
        CGContextTranslateCTM(gc, -imageRect.origin.x, -imageRect.origin.y);
        [image drawAtPoint:CGPointZero];
    } CGContextRestoreGState(gc);
}

I'll also use a little helper method that creates a CGRect from an array of two X coordinates and an array of two Y coordinates:

static CGRect rect(CGFloat *xs, CGFloat *ys) {
    return CGRectMake(xs[0], ys[0], xs[1] - xs[0], ys[1] - ys[0]);
}

With those helpers, I can implement drawRect:. First I handle the easy cases where there is either no image to draw, or no defined fixedRect:

- (void)drawRect:(CGRect)dirtyRect {
    UIImage *image = self.image;
    if (!image)
        return;

    CGRect imageBounds = (CGRect){ CGPointZero, image.size };
    CGRect viewBounds = self.bounds;

    CGRect imageFixedRect = self.fixedRect;
    if (CGRectIsNull(imageFixedRect)) {
        [image drawInRect:viewBounds];
        return;
    }

Then I compute the rectangle in view coordinates where the fixed part of the image will be drawn:

    CGPoint imageFixedCenter = self.fixedCenter;
    CGRect viewFixedRect = CGRectOffset(imageFixedRect, imageFixedCenter.x - imageFixedRect.size.width / 2, imageFixedCenter.y - imageFixedRect.size.height / 2);

Next I make an array of the four (yes, four) X coordinates that define the edges of the slices in view coordinate space, and a similar array for the Y coordinates, and two more arrays for the coordinates in image coordinate space:

    CGFloat viewSlicesX[4] = { viewBounds.origin.x, viewFixedRect.origin.x, CGRectGetMaxX(viewFixedRect), CGRectGetMaxX(viewBounds) };
    CGFloat viewSlicesY[4] = { viewBounds.origin.y, viewFixedRect.origin.y, CGRectGetMaxY(viewFixedRect), CGRectGetMaxY(viewBounds) };
    CGFloat imageSlicesX[4] = { imageBounds.origin.x, imageFixedRect.origin.x, CGRectGetMaxX(imageFixedRect), CGRectGetMaxX(imageBounds) };
    CGFloat imageSlicesY[4] = { imageBounds.origin.y, imageFixedRect.origin.y, CGRectGetMaxY(imageFixedRect), CGRectGetMaxY(imageBounds) };

And finally the easy part, drawing the slices:

    for (int y = 0; y < 3; ++y) {
        for (int x = 0; x < 3; ++x) {
            [self drawRect:rect(imageSlicesX + x, imageSlicesY + y) ofImage:image inRect:rect(viewSlicesX + x, viewSlicesY + y)];
        }
    }
}

It's a little laggy on my iPad 2.

This version of my test project is on github.

查看更多
手持菜刀,她持情操
3楼-- · 2019-07-19 18:49

So my first attempt (in my other answer to this question), doing everything in drawRect:, was a little laggy on my iPad 2. I decided to redo it by creating a separate CALayer for each slice and let Core Animation worry about scaling the slices.

In this version, the work is done in the layoutSubviews method of my custom UIView subclass. The system calls layoutSubviews whenever the view's size changes (including when the view first appears). We can also ask the system to call layoutSubviews using the setNeedsLayout message. The system automatically coalesces layout requests like it coalesces draw requests.

I'll need three private instance variables:

@implementation OutstretchView {
    CALayer *_slices[3][3];
    BOOL _imageDidChange : 1;
    BOOL _hasSlices : 1;
}

My layoutSubviews method starts by handling the easy cases of no image or no fixedRect:

- (void)layoutSubviews {
    [super layoutSubviews];

    if (!self.image) {
        [self hideSlices];
        self.layer.contents = nil;
        return;
    }

    if (CGRectIsNull(self.fixedRect)) {
        [self hideSlices];
        self.layer.contents = (__bridge id)self.image.CGImage;
        return;
    }

Then it lazily creates the nine slice layers if I haven't created them yet:

    if (!_hasSlices)
        [self makeSlices];

It recomputes the images for the slice layers if the image has changed. The _imageDidChange flag is set in setImage:.

    if (_imageDidChange) {
        [self setSliceImages];
        _imageDidChange = NO;
    }

Finally, it sets the frame of each of the nine slice layers.

    [self setSliceFrames];
}

Of course, all the real work happens in the helper methods, but they're pretty simple. Hiding the slices (when there's no image or no fixedRect) is trivial:

- (void)hideSlices {
    if (!_hasSlices)
        return;
    for (int y = 0; y < 3; ++y) {
        for (int x = 0; x < 3; ++x) {
            _slices[y][x].hidden = YES;
        }
    }
}

Creating the slice layers is also trivial:

- (void)makeSlices {
    if (_hasSlices)
        return;
    CALayer *myLayer = self.layer;
    for (int y = 0; y < 3; ++y) {
        for (int x = 0; x < 3; ++x) {
            _slices[y][x] = [CALayer layer];
            [myLayer addSublayer:_slices[y][x]];
        }
    }
    _hasSlices = YES;
}

To make the slice images and set the slice layer frames, I'll need the rect helper function from my other answer:

static CGRect rect(CGFloat *xs, CGFloat *ys) {
    return CGRectMake(xs[0], ys[0], xs[1] - xs[0], ys[1] - ys[0]);
}

Creating the slice images requires a little work, because I have to compute the coordinates of the boundaries between the slices. I use CGImageCreateWithImageInRect to create the slice images from the user-supplied image.

- (void)setSliceImages {
    UIImage *image = self.image;
    CGImageRef cgImage = image.CGImage;
    CGFloat scale = image.scale;
    CGRect fixedRect = self.fixedRect;
    fixedRect.origin.x *= scale;
    fixedRect.origin.y *= scale;
    fixedRect.size.width *= scale;
    fixedRect.size.height *= scale;
    CGFloat xs[4] = { 0, fixedRect.origin.x, CGRectGetMaxX(fixedRect), CGImageGetWidth(cgImage) };
    CGFloat ys[4] = { 0, fixedRect.origin.y, CGRectGetMaxY(fixedRect), CGImageGetHeight(cgImage) };

    for (int y = 0; y < 3; ++y) {
        for (int x = 0; x < 3; ++x) {
            CGImageRef imageSlice = CGImageCreateWithImageInRect(cgImage, rect(xs + x, ys + y));
            _slices[y][x].contents = (__bridge id)imageSlice;
            CGImageRelease(imageSlice);
        }
    }
}

Setting the slice layer frames is similar, although I don't have to handle the image scale here. (Maybe I should be using the UIScreen scale? Hmm. It's confusing. I haven't tried it on a Retina device.)

- (void)setSliceFrames {
    CGRect bounds = self.bounds;
    CGRect fixedRect = self.fixedRect;
    CGPoint fixedCenter = self.fixedCenter;
    fixedRect = CGRectOffset(fixedRect, fixedCenter.x - fixedRect.size.width / 2, fixedCenter.y - fixedRect.size.height / 2);
    CGFloat xs[4] = { bounds.origin.x, fixedRect.origin.x, CGRectGetMaxX(fixedRect), CGRectGetMaxX(bounds) };
    CGFloat ys[4] = { bounds.origin.y, fixedRect.origin.y, CGRectGetMaxY(fixedRect), CGRectGetMaxY(bounds) };

    for (int y = 0; y < 3; ++y) {
        for (int x = 0; x < 3; ++x) {
            _slices[y][x].frame = rect(xs + x, ys + y);
        }
    }
}

This version seems much smoother on my iPad 2. It might work very well on older devices too.

This version of my test project is on github too.

查看更多
登录 后发表回答