Search code examples
swiftuikituiimageviewuiimage

Cannot force image to be rounded in custom UIImageView


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

Solution

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