Search code examples
iosswiftuiimageviewuiimage

Building a circular facepile of profile pictures in Swift: how to have the last photo tucked under the first?


I am trying to build a UIView that has a few UIImageViews arranged in a circular, overlapping manner (see image below). Let's say we have N images. Drawing out the first N - 1 is easy, just use sin/cos functions to arrange the centers of the UIImageViews around a circle. The problem is with the last image that seemingly has two z-index values! I know this is possible since kik messenger has similar group profile photos.

The best idea I have come up so far is taking the last image, split into something like "top half" and "bottom half" and assign different z-values for each. This seems doable when the image is the left-most one, but what happens if the image is the top most? In this case, I would need to split left and right instead of top and bottom.

Because of this problem, it's probably not top, left, or right, but more like a split across some imaginary axis from the center of the overall facepile through the center of the UIImageView. How would I do that?!

enter image description here

Below Code Will Layout UIImageView's in Circle

You would need to import SDWebImage and provide some image URLs to run the code below.

import Foundation
import UIKit
import SDWebImage

class EventDetailsFacepileView: UIView {
    static let dimension: CGFloat = 66.0
    static let radius: CGFloat = dimension / 1.68
    
    private var profilePicViews: [UIImageView] = []
    var profilePicURLs: [URL] = [] {
        didSet {
            updateView()
        }
    }
    
    func updateView() {
        self.profilePicViews = profilePicURLs.map({ (profilePic) -> UIImageView in
            let imageView = UIImageView()
            imageView.sd_setImage(with: profilePic)
            imageView.roundImage(imageDimension: EventDetailsFacepileView.dimension, showsBorder: true)
            imageView.sd_imageTransition = .fade
            return imageView
        })
        self.profilePicViews.forEach { (imageView) in
            self.addSubview(imageView)
        }
        self.setNeedsLayout()
        self.layer.borderColor = UIColor.green.cgColor
        self.layer.borderWidth = 2
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        let xOffset: CGFloat = 0
        let yOffset: CGFloat = 0
        
        let center = CGPoint(x: self.bounds.size.width / 2, y: self.bounds.size.height / 2)
        let radius: CGFloat =  EventDetailsFacepileView.radius
        let angleStep: CGFloat = 2 * CGFloat(Double.pi) / CGFloat(profilePicViews.count)
        var count = 0
        for profilePicView in profilePicViews {
            let xPos = center.x + CGFloat(cosf(Float(angleStep) * Float(count))) * (radius - xOffset)
            let yPos = center.y + CGFloat(sinf(Float(angleStep) * Float(count))) * (radius - yOffset)
            profilePicView.frame = CGRect(origin: CGPoint(x: xPos, y: yPos),
                                          size: CGSize(width: EventDetailsFacepileView.dimension, height: EventDetailsFacepileView.dimension))
            count += 1
        }
    }
    
    override func sizeThatFits(_ size: CGSize) -> CGSize {
        let requiredSize = EventDetailsFacepileView.dimension + EventDetailsFacepileView.radius
        return CGSize(width: requiredSize,
                      height: requiredSize)
    }
}


Solution

  • I don't think you'll have much success trying to split images to get over/under z-indexes.

    One approach is to use masks to make it appear that the image views are overlapped.

    The general idea would be:

    • subclass UIImageView
    • in layoutSubviews()
    • apply cornerRadius to layer to make the image round
    • get a rect from the "overlapping view"
    • convert that rect to local coordinates
    • expand that rect by the desired width of the "outline"
    • get an oval path from that rect
    • combine it with a path from self
    • apply it as a mask layer

    Here is an example....

    I was not entirely sure what your sizing calculations were doing... trying to use your EventDetailsFacepileView as-is gave me small images in the lower-right corner of the view?

    So, I modified your EventDetailsFacepileView in a couple ways:

    • uses local images named "pro1" through "pro5" (you should be able to replace with your SDWebImage)
    • uses auto-layout constraints instead of explicit frames
    • uses MyOverlapImageView class to handle the masking

    Code - no @IBOutlet connections, so just set a blank view controller to OverlapTestViewController:

    class OverlapTestViewController: UIViewController {
        
        let facePileView = MyFacePileView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            facePileView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(facePileView)
            
            facePileView.dimension = 120
            let sz = facePileView.sizeThatFits(.zero)
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                facePileView.widthAnchor.constraint(equalToConstant: sz.width),
                facePileView.heightAnchor.constraint(equalTo: facePileView.widthAnchor),
                facePileView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                facePileView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
            ])
            
            facePileView.profilePicNames = [
                "pro1", "pro2", "pro3", "pro4", "pro5"
            ]
            
        }
        
    }
    
    class MyFacePileView: UIView {
        var dimension: CGFloat = 66.0
        lazy var radius: CGFloat = dimension / 1.68
        
        private var profilePicViews: [MyOverlapImageView] = []
        
        var profilePicNames: [String] = [] {
            didSet {
                updateView()
            }
        }
        
        func updateView() {
            self.profilePicViews = profilePicNames.map({ (profilePic) -> MyOverlapImageView in
                let imageView = MyOverlapImageView()
                if let img = UIImage(named: profilePic) {
                    imageView.image = img
                }
                return imageView
            })
            
            // add MyOverlapImageViews to self
            //  and set width / height constraints
            self.profilePicViews.forEach { (imageView) in
                self.addSubview(imageView)
                imageView.translatesAutoresizingMaskIntoConstraints = false
                imageView.widthAnchor.constraint(equalToConstant: dimension).isActive = true
                imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor).isActive = true
            }
            
            // start at "12 o'clock"
            var curAngle: CGFloat = .pi * 1.5
            // angle increment
            let incAngle: CGFloat = ( 360.0 / CGFloat(self.profilePicViews.count) ) * .pi / 180.0
    
            // calculate position for each image view
            //  set center constraints
            self.profilePicViews.forEach { imgView in
                let xPos = cos(curAngle) * radius
                let yPos = sin(curAngle) * radius
                imgView.centerXAnchor.constraint(equalTo: centerXAnchor, constant: xPos).isActive = true
                imgView.centerYAnchor.constraint(equalTo: centerYAnchor, constant: yPos).isActive = true
                curAngle += incAngle
            }
            
            // set "overlapView" property for each image view
            let n = self.profilePicViews.count
            for i in (1..<n).reversed() {
                self.profilePicViews[i].overlapView = self.profilePicViews[i-1]
            }
            self.profilePicViews[0].overlapView = self.profilePicViews[n - 1]
    
            self.layer.borderColor = UIColor.green.cgColor
            self.layer.borderWidth = 2
            
        }
        
        override func sizeThatFits(_ size: CGSize) -> CGSize {
            let requiredSize = dimension * 2.0 + radius / 2.0
            return CGSize(width: requiredSize,
                          height: requiredSize)
        }
    
    }
    
    class MyOverlapImageView: UIImageView {
        
        // reference to the view that is overlapping me
        weak var overlapView: MyOverlapImageView?
        
        // width of "outline"
        var outlineWidth: CGFloat = 6
        
        override func layoutSubviews() {
            super.layoutSubviews()
            
            // make image round
            layer.cornerRadius = bounds.size.width * 0.5
            layer.masksToBounds = true
    
            let mask = CAShapeLayer()
            
            if let v = overlapView {
                // get bounds from overlapView
                //  converted to self
                //  inset by outlineWidth (negative numbers will make it grow)
                let maskRect = v.convert(v.bounds, to: self).insetBy(dx: -outlineWidth, dy: -outlineWidth)
                // oval path from mask rect
                let path = UIBezierPath(ovalIn: maskRect)
                // path from self bounds
                let clipPath = UIBezierPath(rect: bounds)
                // append paths
                clipPath.append(path)
                mask.path = clipPath.cgPath
                mask.fillRule = .evenOdd
                // apply mask
                layer.mask = mask
            }
        }
        
    }
    

    Result:

    enter image description here

    (I grabbed random images by searching google for sample profile pictures)