I Want to Draw Custom Shape Like Apple Shape on UIview

Drawing a custom view using UIBezierPath results in a non-symmetrical shape

Here is another rendition:

@IBDesignable
open class PointerView: UIView {

/// The left-top and left-bottom curvature
@IBInspectable var cornerRadius: CGFloat = 15 { didSet { updatePath() } }

/// The radius for the right tip
@IBInspectable var rightCornerRadius: CGFloat = 10 { didSet { updatePath() } }

/// The radius for the top right and bottom right curvature
@IBInspectable var rightEdgeRadius: CGFloat = 10 { didSet { updatePath() } }

/// The fill color
@IBInspectable var fillColor: UIColor = .blue { didSet { shapeLayer.fillColor = fillColor.cgColor } }

/// The stroke color
@IBInspectable var strokeColor: UIColor = .clear { didSet { shapeLayer.strokeColor = strokeColor.cgColor } }

/// The angle of the tip
@IBInspectable var angle: CGFloat = 90 { didSet { updatePath() } }

/// The line width
@IBInspectable var lineWidth: CGFloat = 0 { didSet { updatePath() } }

/// The shape layer for the pointer
private lazy var shapeLayer: CAShapeLayer = {
let _shapeLayer = CAShapeLayer()
_shapeLayer.fillColor = fillColor.cgColor
_shapeLayer.strokeColor = strokeColor.cgColor
_shapeLayer.lineWidth = lineWidth
return _shapeLayer
}()

public override init(frame: CGRect) {
super.init(frame: frame)

configure()
}

public required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)

configure()
}

private func configure() {
layer.addSublayer(shapeLayer)
}

open override func layoutSubviews() {
super.layoutSubviews()

updatePath()
}

private func updatePath() {
let path = UIBezierPath()

let offset = lineWidth / 2
let boundingRect = bounds.insetBy(dx: offset, dy: offset)

let arrowTop = CGPoint(x: boundingRect.maxX - boundingRect.height / 2 / tan(angle * .pi / 180 / 2), y: boundingRect.minY)
let arrowRight = CGPoint(x: boundingRect.maxX, y: boundingRect.midY)
let arrowBottom = CGPoint(x: boundingRect.maxX - boundingRect.height / 2 / tan(angle * .pi / 180 / 2), y: boundingRect.maxY)
let start = CGPoint(x: boundingRect.minX + cornerRadius, y: boundingRect.minY)

// top left
path.move(to: start)
path.addQuadCurve(to: CGPoint(x: boundingRect.minX, y: boundingRect.minY + cornerRadius), controlPoint: CGPoint(x: boundingRect.minX, y: boundingRect.minY))

// left
path.addLine(to: CGPoint(x: boundingRect.minX, y: boundingRect.maxY - cornerRadius))

// lower left
path.addQuadCurve(to: CGPoint(x: boundingRect.minX + cornerRadius, y: boundingRect.maxY), controlPoint: CGPoint(x: boundingRect.minX, y: boundingRect.maxY))

// bottom
path.addLine(to: calculate(from: path.currentPoint, to: arrowBottom, less: rightEdgeRadius))

// bottom right (before tip)
path.addQuadCurve(to: calculate(from: arrowRight, to: arrowBottom, less: rightEdgeRadius), controlPoint: arrowBottom)

// bottom edge of tip
path.addLine(to: calculate(from: path.currentPoint, to: arrowRight, less: rightCornerRadius))

// tip
path.addQuadCurve(to: calculate(from: arrowTop, to: arrowRight, less: rightCornerRadius), controlPoint: arrowRight)

// top edge of tip
path.addLine(to: calculate(from: path.currentPoint, to: arrowTop, less: rightEdgeRadius))

// top right (after tip)
path.addQuadCurve(to: calculate(from: start, to: arrowTop, less: rightEdgeRadius), controlPoint: arrowTop)

path.close()

shapeLayer.lineWidth = lineWidth
shapeLayer.path = path.cgPath
}

/// Calculate some point between `startPoint` and `endPoint`, but `distance` from `endPoint
///
/// - Parameters:
/// - startPoint: The starting point.
/// - endPoint: The ending point.
/// - distance: Distance from the ending point
/// - Returns: Returns the point that is `distance` from the `endPoint` as you travel from `startPoint` to `endPoint`.

private func calculate(from startPoint: CGPoint, to endPoint: CGPoint, less distance: CGFloat) -> CGPoint {
let angle = atan2(endPoint.y - startPoint.y, endPoint.x - startPoint.x)
let totalDistance = hypot(endPoint.y - startPoint.y, endPoint.x - startPoint.x) - distance

return CGPoint(x: startPoint.x + totalDistance * cos(angle),
y: startPoint.y + totalDistance * sin(angle))
}
}

And because that is @IBDesignable, I can put it in a separate framework target and then optionally use it (and customize it) right in Interface Builder:

Sample Image

The only change I made in parameters was to not use the width of the tip, but rather the angle of the tip. That way, if the size changes as constraints (or whatever) change, it preserves the desired shape.

I also changed this to use a CAShapeLayer rather that a custom draw(_:) method to enjoy any efficiencies that Apple has built in to shape layers.

iOS Draw custom view with bottom border

I would do something like

CGContextRef context = UIGraphicsGetCurrentContext();

CGContextClearRect(context, rect);

CGContextSaveGState(context);

CGFloat cornerRadius = 10.0f;

CGFloat redBorderHeight = 4.0f;

CGFloat minx = 0.0f;
CGFloat miny = 0.0f;
CGFloat midx = rect.size.width/2;
CGFloat midy = rect.size.height/2;
CGFloat maxx = rect.size.width;
CGFloat maxy = rect.size.height;

CGMutablePathRef path = CGPathCreateMutable();
CGPathMoveToPoint(path, nil, minx, midy);
CGPathAddArcToPoint(path, nil, minx, miny, midx, miny, 0);
CGPathAddArcToPoint(path, nil, maxx, miny, maxx, midy, 0);
CGPathAddArcToPoint(path, nil, maxx, maxy, midx, maxy, 0);
CGPathAddArcToPoint(path, nil, minx, maxy, minx, midy, cornerRadius);
CGPathCloseSubpath(path);

CGContextAddPath(context, path);

CGContextSetFillColorWithColor(context, [UIColor redColor].CGColor);

CGContextDrawPath(context, kCGPathFill);

CGContextRestoreGState(context);

midy = (rect.size.height-redBorderHeight)/2;
maxy = rect.size.height-redBorderHeight;

CGMutablePathRef path2 = CGPathCreateMutable();
CGPathMoveToPoint(path2, nil, minx, midy);
CGPathAddArcToPoint(path2, nil, minx, miny, midx, miny, 0);
CGPathAddArcToPoint(path2, nil, maxx, miny, maxx, midy, 0);
CGPathAddArcToPoint(path2, nil, maxx, maxy, midx, maxy, 0);
CGPathAddArcToPoint(path2, nil, minx, maxy, minx, midy, cornerRadius);
CGPathCloseSubpath(path2);

CGContextAddPath(context, path2);

CGContextSetFillColorWithColor(context, [UIColor blackColor].CGColor);

CGContextDrawPath(context, kCGPathFill);

How to find where to draw text inside an irregular shape represented by a UIBezierPath?

TextKit was built for tasks like this. You can create an array of paths outside of your bezier shape path and then set it as your textView's exclusionPaths:

textView.textContainer.exclusionPaths = [pathsAroundYourBezier];

Keep in mind that the exclusion paths are paths where text in the container will not be displayed. Apple documentation here: https://developer.apple.com/documentation/uikit/nstextcontainer/1444569-exclusionpaths

UPDATE DUE TO BUGS WITH EXCLUSION PATHS AT THE BEGINNING OF TEXTVIEW'S:

I've come up with a way to find where in a path text can fit.

Usage:

    let pathVisible = rectPathSharpU(CGSize(width: 100, height: 125), origin: CGPoint(x: 0, y: 0))
let shapeLayer = CAShapeLayer()
shapeLayer.path = pathVisible.cgPath
shapeLayer.strokeColor = UIColor.green.cgColor
shapeLayer.backgroundColor = UIColor.clear.cgColor
shapeLayer.lineWidth = 3
self.view.layer.addSublayer(shapeLayer)

let path = rectPathSharpU(CGSize(width: 100, height: 125), origin: CGPoint(x: 0, y: 0))
let fittingRect = findFirstRect(path: path, thatFits: "A".size())!
print("fittingRect: \(fittingRect)")
let label = UILabel.init(frame: fittingRect)
label.text = "A"
self.view.addSubview(label)

Output:

There may be cases with curved paths that will need to be taken into account, perhaps by iterating through every y point in a path bounds until a sizable space is found.

The function to find the first fitting rect:

func findFirstRect(path: UIBezierPath, thatFits: CGSize) -> CGRect? {
let points = path.cgPath.points
allPoints: for point in points {
var checkpoint = point
var size = CGSize(width: 0, height: 0)
thisPoint: while size.width <= path.bounds.width {
if path.contains(checkpoint) && path.contains(CGPoint.init(x: checkpoint.x + thatFits.width, y: checkpoint.y + thatFits.height)) {
return CGRect(x: checkpoint.x, y: checkpoint.y, width: thatFits.width, height: thatFits.height)
} else {
checkpoint.x += 1
size.width += 1
continue thisPoint
}
}
}
return nil
}

Extension for finding string size:

extension String {
func size(width:CGFloat = 220.0, font: UIFont = UIFont.systemFont(ofSize: 17.0, weight: .regular)) -> CGSize {
let label:UILabel = UILabel(frame: CGRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude))
label.numberOfLines = 0
label.lineBreakMode = NSLineBreakMode.byWordWrapping
label.font = font
label.text = self

label.sizeToFit()

return CGSize(width: label.frame.width, height: label.frame.height)
}
}

Creating the test path:

func rectPathSharpU(_ size: CGSize, origin: CGPoint) -> UIBezierPath {

// Initialize the path.
let path = UIBezierPath()

// Specify the point that the path should start get drawn.
path.move(to: CGPoint(x: origin.x, y: origin.y))
// add lines to path
path.addLine(to: CGPoint(x: (size.width / 3) + origin.x, y: (size.height / 3 * 2) + origin.y))
path.addLine(to: CGPoint(x: (size.width / 3 * 2) + origin.x, y: (size.height / 3 * 2) + origin.y))
path.addLine(to: CGPoint(x: size.width + origin.x, y: origin.y))
path.addLine(to: CGPoint(x: (size.width) + origin.x, y: size.height + origin.y))
path.addLine(to: CGPoint(x: origin.x, y: size.height + origin.y))

// Close the path. This will create the last line automatically.
path.close()

return path
}

If this doesn't work for paths with a lot of arcs like your picture example, please post the actual path data so I can test with that.

Bonus: I also created a function to find the widest section of a symmetric path, though height isn't taken into account. Though it may be useful:

func findWidestY(path: UIBezierPath) -> CGRect {
var widestSection = CGRect(x: 0, y: 0, width: 0, height: 0)
let points = path.cgPath.points
allPoints: for point in points {
var checkpoint = point
var size = CGSize(width: 0, height: 0)
thisPoint: while size.width <= path.bounds.width {
if path.contains(checkpoint) {
checkpoint.x += 1
size.width += 1
continue thisPoint
} else {
if size.width > widestSection.width {
widestSection = CGRect(x: point.x, y: point.y, width: size.width, height: 1)
}
break thisPoint
}
}
}
return widestSection
}


Related Topics



Leave a reply



Submit