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.
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
}
}
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"):
the question is - what should have rounded corners?
Consider these images during the animation:
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)
}
}