uiimageviewsf-symbolscontentmode

SF Symbol does not work well with scaleAspectFill


I noticed that SF symbol doesn't really work well with scaleAspectFill.

For instance, given a circular aspect fill UIImageView with a thin border, setting its image to UIImage(systemName: "person.crop.circle.fill") would look like the following. Notice the image doesn't extend all the way to the edge.

enter image description here

Setting the image to UIImage(systemName: "person.fill") would look like this. Somehow it messes up the auto layout height constraint (this is the case even when contentMode is set to center).

enter image description here

My workaround is to export SF symbol as png and load them into Xcode, but this is not ideal of course. Am I using SF symbols incorrectly?


Solution

  • 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:

    enter image description here

    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:

    enter image description here

    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:

    enter image description here

    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:

    enter image description here

    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:

    enter image description here

    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
        }
    }
    

    enter image description here

    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:

    enter image description here

    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:

    enter image description here

    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