When we create a UIImage
with an SF Symbol, it gets a vector backing and the image behaves much more like a font character than an image.
It will be much easier to understand if we skip the cornerRadius
for now.
For example, if I set the text of a label to O
and give it a yellow background, it will look like this:

The character does not reach to the edges of the bounding box.
So, when we use: let thisImage = UIImage(systemName: "person.crop.circle.fill")
and set an image view's image, we get this:

As we see, there is "padding" on all 4 sides.
To "remove" the padding, we can convert the CGImage
backing to a UIImage
... but we need to keep a few things in mind.
So, 6 examples - each example is a subclass of this "base" controller:
class MyBaseVC: UIViewController {
let imgViewA = UIImageView()
let imgViewB = UIImageView()
override func viewDidLoad() {
super.viewDidLoad()
[imgViewA, imgViewB].forEach { v in
// use AspectFill
v.contentMode = .scaleAspectFill
// background color so we can see the framing
v.backgroundColor = .systemYellow
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
imgViewA.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
imgViewA.centerXAnchor.constraint(equalTo: g.centerXAnchor),
imgViewA.widthAnchor.constraint(equalToConstant: 160.0),
imgViewA.heightAnchor.constraint(equalTo: imgViewA.widthAnchor),
imgViewB.topAnchor.constraint(equalTo: imgViewA.bottomAnchor, constant: 20.0),
imgViewB.centerXAnchor.constraint(equalTo: g.centerXAnchor),
imgViewB.widthAnchor.constraint(equalToConstant: 160.0),
imgViewB.heightAnchor.constraint(equalTo: imgViewB.widthAnchor),
])
}
}
The first example just shows the layout with empty yellow-background image views:
class Example1VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
}
}
and it looks like this:

The 2nd example create an image from "person.crop.circle.fill" and sets the first image view:
class Example2VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
let nm = "person.crop.circle.fill"
// create UIImage from SF Symbol at "default" size
guard let imgA = UIImage(systemName: nm)?.withTintColor(.lightGray, renderingMode: .alwaysOriginal) else {
fatalError("Could not load SF Symbol: \(nm)!")
}
print("imgA size:", imgA.size)
imgViewA.image = imgA
}
}
Output:

So far, nothing you haven't seen yet.
The code also output the generated image size to the debug console... in this case, imgA size: (20.0, 19.0)
So, we save out that image as a 20x19 png and load it into the 2nd image view:
class Example3VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
let nm = "person.crop.circle.fill"
// create UIImage from SF Symbol at "default" size
guard let imgA = UIImage(systemName: nm)?.withTintColor(.lightGray, renderingMode: .alwaysOriginal) else {
fatalError("Could not load SF Symbol: \(nm)!")
}
guard let imgB = UIImage(named: "imgA20x19") else {
fatalError("Could not load imgA20x19")
}
print("imgA:", imgA.size, "imgB:", imgB.size)
imgViewA.image = imgA
imgViewB.image = imgB
}
}
As expected, because its now a bitmap instead of vector data, it gets unacceptably fuzzy... and, we're still not to the edges:

So, for the 4th example, we'll use the .cgImage
backing data from the generated SF Symbol image to effectively create the bitmap version "on-the-fly":
class Example4VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
let nm = "person.crop.circle.fill"
// create UIImage from SF Symbol at "default" size
guard let imgA = UIImage(systemName: nm)?.withTintColor(.lightGray, renderingMode: .alwaysOriginal) else {
fatalError("Could not load SF Symbol: \(nm)!")
}
// get a cgRef from imgA
guard let cgRef = imgA.cgImage else {
fatalError("Could not get cgImage!")
}
// create imgB from the cgRef
let imgB = UIImage(cgImage: cgRef, scale: imgA.scale, orientation: imgA.imageOrientation)
.withTintColor(.lightGray, renderingMode: .alwaysOriginal)
print("imgA:", imgA.size, "imgB:", imgB.size)
imgViewA.image = imgA
imgViewB.image = imgB
}
}

Getting closer... the image now reaches the edges, but is still blurry.
To fix that, we'll use a "point" configuration when we generate the initial SF Symbol image:
class Example5VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
let nm = "person.crop.circle.fill"
// create UIImage from SF Symbol at "160-pts" size
let cfg = UIImage.SymbolConfiguration(pointSize: 160.0)
guard let imgA = UIImage(systemName: nm, withConfiguration: cfg)?.withTintColor(.lightGray, renderingMode: .alwaysOriginal) else {
fatalError("Could not load SF Symbol: \(nm)!")
}
// get a cgRef from imgA
guard let cgRef = imgA.cgImage else {
fatalError("Could not get cgImage!")
}
// create imgB from the cgRef
let imgB = UIImage(cgImage: cgRef, scale: imgA.scale, orientation: imgA.imageOrientation)
.withTintColor(.lightGray, renderingMode: .alwaysOriginal)
print("imgA:", imgA.size, "imgB:", imgB.size)
imgViewA.image = imgA
imgViewB.image = imgB
}
}
The debug size output shows imgB: (159.5, 159.5)
(so, pretty close to 160x160 size of the image view), and it looks like this:

We now have a crisp rendering, without the "padding" ... and we can add the corner radius and border:
class Example6VC: MyBaseVC {
override func viewDidLoad() {
super.viewDidLoad()
let nm = "person.crop.circle.fill"
// create UIImage from SF Symbol at "160-pts" size
let cfg = UIImage.SymbolConfiguration(pointSize: 160.0)
guard let imgA = UIImage(systemName: nm, withConfiguration: cfg)?.withTintColor(.lightGray, renderingMode: .alwaysOriginal) else {
fatalError("Could not load SF Symbol: \(nm)!")
}
// get a cgRef from imgA
guard let cgRef = imgA.cgImage else {
fatalError("Could not get cgImage!")
}
// create imgB from the cgRef
let imgB = UIImage(cgImage: cgRef, scale: imgA.scale, orientation: imgA.imageOrientation)
.withTintColor(.lightGray, renderingMode: .alwaysOriginal)
print("imgA:", imgA.size, "imgB:", imgB.size)
imgViewA.image = imgA
imgViewB.image = imgB
[imgViewA, imgViewB].forEach { v in
v.layer.cornerRadius = 80
v.layer.borderColor = UIColor.red.cgColor
v.layer.borderWidth = 1
}
}
}
Example 6 result:

As you mentioned in your post, you'll notice the top image view is not exactly round -- that is, it somehow lost its 1:1 ratio.
This is a quirky side-effect of using SF Symbols as images that I have long since given up trying to understand. Different symbols will cause the image view to change size, regardless of constraints.
You can see some discussion here: https://stackoverflow.com/a/66293917/6257435