Cannot force image to be rounded in custom UIImageView

36 Views Asked by At

I have a custom UIImageView that acts like a 'carousel' in that users can swipe it to see an image (which, by the way, I adapted from this excellent post on Medium.

I want the corners to be rounded to 20, but I can't find the correct value for the imageView's content mode.

This is scaleAspectFit enter image description here

This is scaleAspectFill enter image description here

This is scaleToFill enter image description here

What I want to happen is for the image to scale to fill the view and retain its aspect, which I would normally use .scaleAspectFill for. But due to the way this custom view is set up, it turns into this bizarre mess, as you can see.

I've pasted the custom class below - does anyone have any ideas?

class ImageCarouselView: UIView {
    private var images: [UIImage?] = []
    private var index = 0
    private let screenWidth = UIScreen.main.bounds.width

    var delegate: ImageCarouselViewDelegate?

    lazy var previousImageView = imageView(image: nil, contentMode: .scaleAspectFit)
    lazy var currentImageView = imageView(image: nil, contentMode: .scaleAspectFit)
    lazy var nextImageView = imageView(image: nil, contentMode: .scaleAspectFit)
    
    var topView = UIView()

    lazy var previousImageLeadingConstraint: NSLayoutConstraint = {
        return previousImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -screenWidth)
    }()

    lazy var currentImageLeadingConstraint: NSLayoutConstraint = {
        return currentImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0)
    }()

    lazy var nextImageLeadingConstraint: NSLayoutConstraint = {
        return nextImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: screenWidth)
    }()

    convenience init(_ images: [UIImage?]) {
        self.init()
        
        self.images = images


        self.setUpActions()
    }
    
    init() {
        super.init(frame: .zero)
        self.translatesAutoresizingMaskIntoConstraints = false
        
        self.heightAnchor.constraint(greaterThanOrEqualToConstant: 300).isActive = true
        
        
        self.layer.cornerRadius = 20
        self.clipsToBounds = true
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func load(images: [UIImage?]) {
        print("ImageCarouselView - Laod Images")
        self.images = images
        self.setUpActions()
    }
    
    private func setUpActions() {
        setupLayout()
        setupSwipeRecognizer()
        setupImages()
    }

    private func setupLayout() {
        self.subviews.forEach({ $0.removeFromSuperview() })

        addSubview(previousImageView)
        addSubview(currentImageView)
        addSubview(nextImageView)

        previousImageLeadingConstraint = previousImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -screenWidth)
        currentImageLeadingConstraint = currentImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0)
        nextImageLeadingConstraint = nextImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: screenWidth)

        NSLayoutConstraint.activate([
            previousImageLeadingConstraint,
            previousImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
            previousImageView.widthAnchor.constraint(equalToConstant: screenWidth),

            currentImageLeadingConstraint,
            currentImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
            currentImageView.widthAnchor.constraint(equalToConstant: screenWidth),

            nextImageLeadingConstraint,
            nextImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
            nextImageView.widthAnchor.constraint(equalToConstant: screenWidth),
        ])
    }

    private func setupImages() {
        print(images.count)
        guard images.count > 0 else { return }
        
        currentImageView.image = images[self.index]

        guard images.count > 1 else { return }

        if (index == 0) {
            previousImageView.image = images[images.count - 1]
            nextImageView.image = images[index + 1]
        }

        if (index == (images.count - 1)) {
            previousImageView.image = images[index - 1]
            nextImageView.image = images[0]
        }
    }

    private func setupSwipeRecognizer() {
        guard images.count > 1 else { return }

        let leftSwipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipes))
        let rightSwipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipes))

        leftSwipe.direction = .left
        rightSwipe.direction = .right

        self.addGestureRecognizer(leftSwipe)
        self.addGestureRecognizer(rightSwipe)
    }

    @objc private func handleSwipes(_ sender: UISwipeGestureRecognizer) {
        if (sender.direction == .left) {
            showNextImage()
        }

        if (sender.direction == .right) {
            showPreviousImage()
        }
    }

    private func showPreviousImage() {
        previousImageLeadingConstraint.constant = 0
        currentImageLeadingConstraint.constant = screenWidth

        UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseIn, animations: {
            self.layoutIfNeeded()
        }, completion: { _ in
            self.nextImageView = self.currentImageView
            self.currentImageView = self.previousImageView
            self.previousImageView = self.imageView(image: nil, contentMode: .scaleAspectFit)

            self.index = self.index == 0 ? self.images.count - 1 : self.index - 1
            self.delegate?.imageCarouselView(self, didShowImageAt: self.index)
            self.previousImageView.image = self.index == 0 ? self.images[self.images.count - 1] : self.images[self.index - 1]

            self.setupLayout()
        })
    }

    private func showNextImage() {
        nextImageLeadingConstraint.constant = 0
        currentImageLeadingConstraint.constant = -screenWidth

        UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseIn, animations: {
            self.layoutIfNeeded()
        }, completion: { _ in
            self.previousImageView = self.currentImageView
            self.currentImageView = self.nextImageView
            self.nextImageView = self.imageView(image: nil, contentMode: .scaleAspectFit)

            self.index = self.index == (self.images.count - 1) ? 0 : self.index + 1
            self.delegate?.imageCarouselView(self, didShowImageAt: self.index)
            self.nextImageView.image = self.index == (self.images.count - 1) ? self.images[0] : self.images[self.index + 1]

            self.setupLayout()
        })
    }

    func imageView(image: UIImage? = nil, contentMode: UIImageView.ContentMode) -> UIImageView {
        let view = UIImageView()
        
        view.image = image
        view.contentMode = .scaleAspectFit
        view.translatesAutoresizingMaskIntoConstraints = false

        view.backgroundColor = UIColor.init(white: 0.3, alpha: 1)

        return view
    }
}
1

There are 1 best solutions below

1
DonMag On BEST ANSWER

There are many ways to create a "Carousel" view ... this is an interesting approach. It doesn't allow "dragging left-right" - only swiping - but if that's the desired goal then fine.

Couple things it's doing wrong though...

First:

private let screenWidth = UIScreen.main.bounds.width

is a very bad idea. The class will not work unless the view is, actually, the full width of the screen. It also won't adapt to frame changes (such as on device rotation). And, it will fail miserably if the app is running in Multitasking Mode on an iPad, for example.

So, let's use the view width instead, and update it in layoutSubviews():

private var myWidth: CGFloat = 0.0

override func layoutSubviews() {
    super.layoutSubviews()
    
    // if the width has changed...
    //  this will be true on first layout
    //  and on frame change (such as device rotation)
    if myWidth != bounds.width {
        myWidth = bounds.width
        // update image view positions
        previousImageLeadingConstraint.constant = -myWidth
        currentImageLeadingConstraint.constant = 0
        nextImageLeadingConstraint.constant = myWidth
    }
}

Next, the code creates a new image view and completely rebuilds the view hierarchy on every swipe... which is a lot more processing than needed.

Instead, we can re-position the existing image views and update their .image properties on swipe-animation completion.

So, if we assume we start with this (the red-dashed line is the frame of the "slide show view"):

enter image description here

the question is - what should have rounded corners?

Consider these images during the animation:

enter image description here

enter image description here

enter image description here

enter image description here

How you want the corners to look during the animation will determine whether we round the corners of the image views, the view itself, both, or neither.

To simplify the code a little bit more, we can constrain

currentImageView Leading to previousImageView Trailing

and

previousImageView Leading to currentImageView Trailing 

so they "stick together" ... now we only need to manage One "dynamic constraint."


Here is a modified version of your ImageCarouselView class:

class ImageCarouselView: UIView {
    
    // public properties
    public var cornerRadius: CGFloat = 32.0 { didSet { updateCorners() } }
    public var animDuration: Double = 0.3
    public var shouldRoundFrame: Bool = true { didSet { updateCorners() } }
    public var shouldRoundImages: Bool = false { didSet { updateCorners() } }
    
    public var delegate: ImageCarouselViewDelegate?
    
    // private properties
    private var images: [UIImage?] = []
    private var index = 0
    private var myWidth: CGFloat = 0.0
    
    private lazy var previousImageView = imageView(image: nil, contentMode: .scaleAspectFill)
    private lazy var currentImageView = imageView(image: nil, contentMode: .scaleAspectFill)
    private lazy var nextImageView = imageView(image: nil, contentMode: .scaleAspectFill)
    
    private var previousImageLeadingConstraint: NSLayoutConstraint!
    
    convenience init(_ images: [UIImage?]) {
        self.init()
        self.images = images
        self.setUpActions()
    }
    
    init() {
        super.init(frame: .zero)
        self.translatesAutoresizingMaskIntoConstraints = false
        self.heightAnchor.constraint(greaterThanOrEqualToConstant: 300).isActive = true
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func load(images: [UIImage?]) {
        print("ImageCarouselView - Laod Images")
        self.images = images
        self.setUpActions()
    }
    
    private func setUpActions() {
        self.clipsToBounds = true
        setupLayout()
        setupSwipeRecognizer()
        setupImages()
        updateCorners()
    }
    
    private func setupLayout() {
        // this should only get called once, on init
        //  so we shouldn't have any subviews
        //  but in case it gets called again...
        self.subviews.forEach({ $0.removeFromSuperview() })
        
        addSubview(previousImageView)
        addSubview(currentImageView)
        addSubview(nextImageView)
        
        previousImageLeadingConstraint = previousImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: -myWidth)
        
        NSLayoutConstraint.activate([
            previousImageLeadingConstraint,

            // constrain currentImageView and nextImageView leading
            //  so all 3 image views "stick together"
            currentImageView.leadingAnchor.constraint(equalTo: previousImageView.trailingAnchor),
            nextImageView.leadingAnchor.constraint(equalTo: currentImageView.trailingAnchor),

            // all image views centered vertically
            previousImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
            currentImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
            nextImageView.centerYAnchor.constraint(equalTo: centerYAnchor),
            
            // all image views have same width as self
            previousImageView.widthAnchor.constraint(equalTo: self.widthAnchor),
            currentImageView.widthAnchor.constraint(equalTo: self.widthAnchor),
            nextImageView.widthAnchor.constraint(equalTo: self.widthAnchor),
            
            // all image views have same height as self
            previousImageView.heightAnchor.constraint(equalTo: self.heightAnchor),
            currentImageView.heightAnchor.constraint(equalTo: self.heightAnchor),
            nextImageView.heightAnchor.constraint(equalTo: self.heightAnchor),
        ])
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        
        // if the width has changed...
        //  this will be true on first layout
        //  and on frame change (such as device rotation)
        if myWidth != bounds.width {
            myWidth = bounds.width
            // update image view positions
            previousImageLeadingConstraint.constant = -myWidth
        }
    }
    
    private func setupImages() {
        
        guard images.count > 0 else { return }
        
        currentImageView.image = images[self.index]
        
        guard images.count > 1 else { return }
        
        if (index == 0) {
            previousImageView.image = images[images.count - 1]
            nextImageView.image = images[index + 1]
        }
        
        if (index == (images.count - 1)) {
            previousImageView.image = images[index - 1]
            nextImageView.image = images[0]
        }
    }
    
    private func setupSwipeRecognizer() {
        guard images.count > 1 else { return }
        
        let leftSwipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipes))
        let rightSwipe = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipes))
        
        leftSwipe.direction = .left
        rightSwipe.direction = .right
        
        self.addGestureRecognizer(leftSwipe)
        self.addGestureRecognizer(rightSwipe)
    }
    
    @objc private func handleSwipes(_ sender: UISwipeGestureRecognizer) {
        if (sender.direction == .left) {
            showNextImage()
        }
        
        if (sender.direction == .right) {
            showPreviousImage()
        }
    }
    
    private func showPreviousImage() {
        // we're sliding the "connected image views" from left-to-right
        //  so previousImageView - currently "out-of-view on-the-left"
        //  will become visible
        previousImageLeadingConstraint.constant = 0
        
        UIView.animate(withDuration: animDuration, delay: 0.0, options: .curveEaseIn, animations: {
            self.layoutIfNeeded()
        }, completion: { _ in
            
            // move "connected image views" back
            //  so previousImageView will again be "out-of-view on-the-left"
            self.previousImageLeadingConstraint.constant = -self.myWidth
            
            // set nextImageView's image to current image
            self.nextImageView.image = self.currentImageView.image
            // set currentImageView's image to previous image
            self.currentImageView.image = self.previousImageView.image
            
            // update previousImageView's image based on indexing
            self.index = self.index == 0 ? self.images.count - 1 : self.index - 1
            self.delegate?.imageCarouselView(self, didShowImageAt: self.index)
            self.previousImageView.image = self.index == 0 ? self.images[self.images.count - 1] : self.images[self.index - 1]
            
        })
    }
    
    private func showNextImage() {
        
        // we're sliding the "connected image views" from right-to-left
        //  so nextImageView - currently "out-of-view on-the-right"
        //  will become visible
        previousImageLeadingConstraint.constant = -myWidth * 2.0
        
        UIView.animate(withDuration: animDuration, delay: 0.0, options: .curveEaseIn, animations: {
            self.layoutIfNeeded()
        }, completion: { _ in
            
            // move "connected image views" back
            //  so previousImageView will again be "out-of-view on-the-right"
            self.previousImageLeadingConstraint.constant = -self.myWidth

            // set previousImageView's image to current image
            self.previousImageView.image = self.currentImageView.image
            // set currentImageView's image to next image
            self.currentImageView.image = self.nextImageView.image
            
            // update nextImageView's image based on indexing
            self.index = self.index == (self.images.count - 1) ? 0 : self.index + 1
            self.delegate?.imageCarouselView(self, didShowImageAt: self.index)
            self.nextImageView.image = self.index == (self.images.count - 1) ? self.images[0] : self.images[self.index + 1]
            
        })
    }
    
    func imageView(image: UIImage? = nil, contentMode: UIImageView.ContentMode) -> UIImageView {
        
        let view = UIImageView()
        
        view.clipsToBounds = true
        view.image = image
        view.contentMode = contentMode
        view.translatesAutoresizingMaskIntoConstraints = false
        
        view.backgroundColor = UIColor.init(white: 0.3, alpha: 1)
        
        return view
    }
    
    private func updateCorners() {
        // round the corners of self and the image views as specified
        var r: CGFloat
        
        r = self.shouldRoundFrame ? self.cornerRadius : 0.0
        self.layer.cornerRadius = r
        
        r = self.shouldRoundImages ? self.cornerRadius : 0.0
        [previousImageView, currentImageView, nextImageView].forEach { v in
            v.layer.cornerRadius = r
        }
    }
}

protocol ImageCarouselViewDelegate: NSObjectProtocol {
    func imageCarouselView(_ imageCarouselView: ImageCarouselView, didShowImageAt index: Int)
}

and an example view controller:

class SlideShowViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        var images: [UIImage] = []
        
        ["ss01", "ss02", "ss03","ss04",].forEach { sName in
            guard let img = UIImage(named: sName) else {
                fatalError("Could not load image: \(sName)")
            }
            images.append(img)
        }
        
        let slideshowView = ImageCarouselView(images)
        slideshowView.delegate = self
        
        slideshowView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(slideshowView)
        
        let g = view.safeAreaLayoutGuide
        
        // let's make the slideshowView frame
        //  90% of the view width, with max of 600-points
        //  300-points height
        //  centered horizontally and vertically
        
        let maxWidth: CGFloat = 600.0
        let targetW: NSLayoutConstraint = slideshowView.widthAnchor.constraint(equalTo: g.widthAnchor, multiplier: 0.9)
        targetW.priority = .required - 1
        
        NSLayoutConstraint.activate([
            targetW,
            slideshowView.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth),
            slideshowView.heightAnchor.constraint(equalToConstant: 300.0),
            slideshowView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            slideshowView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])
        
        // change these to see the different "corner rounding"
        slideshowView.shouldRoundFrame = true
        slideshowView.shouldRoundImages = true
        
        // if you want to adjust the animation speed
        //slideshowView.animDuration = 1.0
        
    }
    
}
extension SlideShowViewController: ImageCarouselViewDelegate {
    func imageCarouselView(_ imageCarouselView: ImageCarouselView, didShowImageAt index: Int) {
        // do something with index
        print("didShow:", index)
    }
}