How to Add a Uilabel or Catextlayer to a Cgpath in Swift, Similar to Photoshop's Type to Path Feature

Positioning View using anchor point

So, as far as I can tell, alignment guides can't be used in this way – yet. Hopefully this will be coming soon, but in the meantime we can do a little padding and overlay trickery to get the desired effect.

Caveats

  • You will need to have some way of retrieving the font metrics – I'm using CTFont to initialise my Font instances and retrieving metrics that way.
  • As far as I can tell, Playgrounds aren't always representative of how a SwiftUI layout will be laid out on the device, and certain inconsistencies arise. One that I've identified is that the displayScale environment value (and the derived pixelLength value) is not set correctly by default in playgrounds and even previews. Therefore, you have to set this manually in these environments if you want a representative layout (FB7280058).

Overview

We're going to combine a number of SwiftUI features to get the outcome we want here. Specifically, transforms, overlays and the GeometryReader view.

First, we'll align the baseline of our glyph to the baseline of our view. If we have the font's metrics we can use the font's 'descent' to shift our glyph down a little so it sits flush with the baseline – we can use the padding view modifier to help us with this.

Next, we're going to overlay our glyph view with a duplicate view. Why? Because within an overlay we're able to grab the exact metrics of the view underneath. In fact, our overlay will be the only view the user sees, the original view will only be utilised for its metrics.

A couple of simple transforms will position our overlay where we want it, and we'll then hide the view that sits underneath to complete the effect.

Step 1: Set up

First, we're going to need some additional properties to help with our calculations. In a proper project you could organise this into a view modifier or similar, but for conciseness we'll add them to our existing view.

@Environment(\.pixelLength) var pixelLength: CGFloat
@Environment(\.displayScale) var displayScale: CGFloat

We'll also need a our font initialised as a CTFont so we can grab its metrics:

let baseFont: CTFont = {
let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
return CTFontCreateWithFontDescriptor(desc, 48, nil)
}()

Then some calculations. This calculates some EdgeInsets for a text view that will have the effect of moving the text view's baseline to the bottom edge of the enclosing padding view:

var textPadding: EdgeInsets {
let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
return baselineOffsetInsets
}

We'll also add a couple of helper properties to CTFont:

extension CTFont {
var ascent: CGFloat { CTFontGetAscent(self) }
var descent: CGFloat { CTFontGetDescent(self) }
}

And finally we create a new helper function to generate our Text views that uses the CTFont we defined above:

private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
}

Step 2: Adopt our glyphView(_:) in our main body call

This step is simple and has us adopt the glyphView(_:) helper function we define above:

var body: some View {
ZStack {
ForEach(locations) { run in
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.position(run.point)

Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}

This gets us here:

Step 2

Step 3: Baseline shift

Next we shift the baseline of our text view so that it sits flush with the bottom of our enclosing padding view. This is just a case of adding a padding modifier to our new glyphView(_:)function that utilises the padding calculation we define above.

private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
.padding(textPadding) // Added padding modifier
}

Step 3

Notice how the glyphs are now sitting flush with the bottom of their enclosing views.

Step 4: Add an overlay

We need to get the metrics of our glyph so that we are able to accurately place it. However, we can't get those metrics until we've laid out our view. One way around this is to duplicate our view and use one view as a source of metrics that is otherwise hidden, and then present a duplicate view that we position using the metrics we've gathered.

We can do this with the overlay modifier together with a GeometryReader view. And we'll also add a purple border and make our overlay text blue to differentiate it from the previous step.

self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
})
.position(run.point)

Step 4

Step 5: Translate

Making use of the metrics we now have available for us to use, we can shift our overlay up and to the right so that the bottom left corner of the glyph view sits on our red positioning spot.

self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
})
.position(run.point)

Step 5

Step 6: Rotate

Now we have our view in position we can finally rotate.

self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)

Sample Image

Step 7: Hide our workings out

Last step is to hide our source view and set our overlay glyph to its proper colour:

self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.hidden()
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.black)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)

Step 7

The final code

//: A Cocoa based Playground to present user interface

import SwiftUI
import PlaygroundSupport

struct Location: Identifiable {
let id = UUID()
let point: CGPoint
let angle: Double
let string: String
}

let locations = [
Location(point: CGPoint(x: 54.48386479999999, y: 296.4645408), angle: -0.6605166885682314, string: "Y"),
Location(point: CGPoint(x: 74.99159120000002, y: 281.6336352), angle: -0.589411952788817, string: "o"),
]

struct ContentView: View {

@Environment(\.pixelLength) var pixelLength: CGFloat
@Environment(\.displayScale) var displayScale: CGFloat

let baseFont: CTFont = {
let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
return CTFontCreateWithFontDescriptor(desc, 48, nil)
}()

var textPadding: EdgeInsets {
let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
return baselineOffsetInsets
}

var body: some View {
ZStack {
ForEach(locations) { run in
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.hidden()
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.black)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)

Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}

private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
.padding(textPadding)
}
}

private extension CTFont {
var ascent: CGFloat { CTFontGetAscent(self) }
var descent: CGFloat { CTFontGetDescent(self) }
}

PlaygroundPage.current.setLiveView(
ContentView()
.environment(\.displayScale, NSScreen.main?.backingScaleFactor ?? 1.0)
.frame(width: 640, height: 480)
.background(Color.white)
)

And that's it. It's not perfect, but until SwiftUI gives us an API that allows us to use alignment anchors to anchor our transforms, it might get us by!

Rounded UIView using CALayers - only some corners - How?

I used the answer over at How do I create a round cornered UILabel on the iPhone? and the code from How is a rounded rect view with transparency done on iphone? to make this code.

Then I realized I'd answered the wrong question (gave a rounded UILabel instead of UIImage) so I used this code to change it:

http://discussions.apple.com/thread.jspa?threadID=1683876

Make an iPhone project with the View template. In the view controller, add this:

- (void)viewDidLoad
{
CGRect rect = CGRectMake(10, 10, 200, 100);
MyView *myView = [[MyView alloc] initWithFrame:rect];
[self.view addSubview:myView];
[super viewDidLoad];
}

MyView is just a UIImageView subclass:

@interface MyView : UIImageView
{
}

I'd never used graphics contexts before, but I managed to hobble together this code. It's missing the code for two of the corners. If you read the code, you can see how I implemented this (by deleting some of the CGContextAddArc calls, and deleting some of the radius values in the code. The code for all corners is there, so use that as a starting point and delete the parts that create corners you don't need. Note that you can make rectangles with 2 or 3 rounded corners too if you want.

The code's not perfect, but I'm sure you can tidy it up a little bit.

static void addRoundedRectToPath(CGContextRef context, CGRect rect, float radius, int roundedCornerPosition)
{

// all corners rounded
// CGContextMoveToPoint(context, rect.origin.x, rect.origin.y + radius);
// CGContextAddLineToPoint(context, rect.origin.x, rect.origin.y + rect.size.height - radius);
// CGContextAddArc(context, rect.origin.x + radius, rect.origin.y + rect.size.height - radius,
// radius, M_PI / 4, M_PI / 2, 1);
// CGContextAddLineToPoint(context, rect.origin.x + rect.size.width - radius,
// rect.origin.y + rect.size.height);
// CGContextAddArc(context, rect.origin.x + rect.size.width - radius,
// rect.origin.y + rect.size.height - radius, radius, M_PI / 2, 0.0f, 1);
// CGContextAddLineToPoint(context, rect.origin.x + rect.size.width, rect.origin.y + radius);
// CGContextAddArc(context, rect.origin.x + rect.size.width - radius, rect.origin.y + radius,
// radius, 0.0f, -M_PI / 2, 1);
// CGContextAddLineToPoint(context, rect.origin.x + radius, rect.origin.y);
// CGContextAddArc(context, rect.origin.x + radius, rect.origin.y + radius, radius,
// -M_PI / 2, M_PI, 1);

// top left
if (roundedCornerPosition == 1) {
CGContextMoveToPoint(context, rect.origin.x, rect.origin.y + radius);
CGContextAddLineToPoint(context, rect.origin.x, rect.origin.y + rect.size.height - radius);
CGContextAddArc(context, rect.origin.x + radius, rect.origin.y + rect.size.height - radius,
radius, M_PI / 4, M_PI / 2, 1);
CGContextAddLineToPoint(context, rect.origin.x + rect.size.width,
rect.origin.y + rect.size.height);
CGContextAddLineToPoint(context, rect.origin.x + rect.size.width, rect.origin.y);
CGContextAddLineToPoint(context, rect.origin.x, rect.origin.y);
}

// bottom left
if (roundedCornerPosition == 2) {
CGContextMoveToPoint(context, rect.origin.x, rect.origin.y);
CGContextAddLineToPoint(context, rect.origin.x, rect.origin.y + rect.size.height);
CGContextAddLineToPoint(context, rect.origin.x + rect.size.width,
rect.origin.y + rect.size.height);
CGContextAddLineToPoint(context, rect.origin.x + rect.size.width, rect.origin.y);
CGContextAddLineToPoint(context, rect.origin.x + radius, rect.origin.y);
CGContextAddArc(context, rect.origin.x + radius, rect.origin.y + radius, radius,
-M_PI / 2, M_PI, 1);
}

// add the other corners here

CGContextClosePath(context);
CGContextRestoreGState(context);
}

-(UIImage *)setImage
{
UIImage *img = [UIImage imageNamed:@"my_image.png"];
int w = img.size.width;
int h = img.size.height;

CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGContextRef context = CGBitmapContextCreate(NULL, w, h, 8, 4 * w, colorSpace, kCGImageAlphaPremultipliedFirst);

CGContextBeginPath(context);
CGRect rect = CGRectMake(0, 0, w, h);

addRoundedRectToPath(context, rect, 50, 1);
CGContextClosePath(context);
CGContextClip(context);

CGContextDrawImage(context, rect, img.CGImage);

CGImageRef imageMasked = CGBitmapContextCreateImage(context);
CGContextRelease(context);
CGColorSpaceRelease(colorSpace);
[img release];

return [UIImage imageWithCGImage:imageMasked];
}

alt text http://nevan.net/skitch/skitched-20100224-092237.png

Don't forget that you'll need to get the QuartzCore framework in there for this to work.

Dotted / Dashed border for UITextView / UITextField

You could try, for example, next approach:

1) Create image that will represent your border (for example: one dot and space)

2) Add image to project.

3) Set border (as in code in your question) and set color with pattern:

[self.textFieldCardTitle.layer setBorderWidth:6.0];
[self.textFieldCardTitle.layer setBorderColor:[[UIColor colorWithPatternImage:[UIImage imageNamed:@"dashed_white.png"]] CGColor]];

As border is drawn along 4 sides (left, right, bottom, top) you should use square image: for example, pixel in middle is black and pixels around it are transparent. So copies of that image will be placed around the view.

How to control shadow spread and blur?

Here's how to apply all 6 Sketch shadow properties to a UIView's layer with near perfect accuracy:

extension CALayer {
func applySketchShadow(
color: UIColor = .black,
alpha: Float = 0.5,
x: CGFloat = 0,
y: CGFloat = 2,
blur: CGFloat = 4,
spread: CGFloat = 0)
{
masksToBounds = false
shadowColor = color.cgColor
shadowOpacity = alpha
shadowOffset = CGSize(width: x, height: y)
shadowRadius = blur / 2.0
if spread == 0 {
shadowPath = nil
} else {
let dx = -spread
let rect = bounds.insetBy(dx: dx, dy: dx)
shadowPath = UIBezierPath(rect: rect).cgPath
}
}
}

Say we want to represent the following:

Sample Image

You can easily do this via:

myView.layer.applySketchShadow(
color: .black,
alpha: 0.5,
x: 0,
y: 0,
blur: 4,
spread: 0)

or more succinctly:

myView.layer.applySketchShadow(y: 0)

Example:

Sample Image

Left: iPhone 8 UIView screenshot; right: Sketch rectangle.

Note:

  • When using a non-zero spread, it hardcodes a path based on the bounds of the CALayer. If the layer's bounds ever change, you'd want to call the applySketchShadow() method again.


Related Topics



Leave a reply



Submit