Search code examples
swiftuiimageviewuiimagemaskalpha

Adding a partial mask over an UIImageView


I want to add a 0.5 alpha mask over just one part of an image (that I will calculate in code). Basically, it's a 5-star rating control, but the stars are not one color, but some nice images like this:

Star image

The image has a transparent background that I need to respect. So I'd like to be able to add a mask or to somehow set the alpha of just half of the image for example, when your rating is 3.5. (2 full stars and one with half of it with less alpha)

I can't just put a UIView over it with 0.5 alpha, because that will also impact with the background where the stars are displayed.

Any ideas?


Solution

  • You can use a CAGradientLayer as a mask:

        gLayer.startPoint = CGPoint.zero
        gLayer.endPoint = CGPoint(x: 1.0, y: 0.0)
        gLayer.locations = [
            0.0, 0.5, 0.5, 1.0,
        ]
        gLayer.colors = [
            UIColor.black.cgColor,
            UIColor.black.cgColor,
            UIColor.black.withAlphaComponent(0.5).cgColor,
            UIColor.black.withAlphaComponent(0.5).cgColor,
        ]
    

    This would create a horizontal gradient, with the left half full alpha and the right half 50% alpha.

    So, a white view with this as a mask would look like this:

    enter image description here

    If we set the image to your star, it looks like this:

    enter image description here

    If we want the star to be "75% filled" we change the locations:

        gLayer.locations = [
            0.0, 0.75, 0.75, 1.0,
        ]
    

    resulting in:

    enter image description here

    Here is an example implementation for a "Five Star" rating view:

    @IBDesignable
    class FiveStarRatingView: UIView {
        
        @IBInspectable
        public var rating: CGFloat = 0.0 {
            didSet {
                var r = rating
                stack.arrangedSubviews.forEach {
                    if let v = $0 as? PercentImageView {
                        v.percent = min(1.0, r)
                        r -= 1.0
                    }
                }
            }
        }
        
        @IBInspectable
        public var ratingImage: UIImage = UIImage() {
            didSet {
                stack.arrangedSubviews.forEach {
                    if let v = $0 as? PercentImageView {
                        v.image = ratingImage
                    }
                }
            }
        }
        
        @IBInspectable
        public var tranparency: CGFloat = 0.5 {
            didSet {
                stack.arrangedSubviews.forEach {
                    if let v = $0 as? PercentImageView {
                        v.tranparency = tranparency
                    }
                }
            }
        }
        
        override var intrinsicContentSize: CGSize {
            return CGSize(width: 100.0, height: 20.0)
        }
        
        private let stack: UIStackView = {
            let v = UIStackView()
            v.axis = .horizontal
            v.alignment = .center
            v.distribution = .fillEqually
            v.translatesAutoresizingMaskIntoConstraints = false
            return v
        }()
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() -> Void {
            addSubview(stack)
            // constrain stack view to all 4 sides
            NSLayoutConstraint.activate([
                stack.topAnchor.constraint(equalTo: topAnchor),
                stack.leadingAnchor.constraint(equalTo: leadingAnchor),
                stack.trailingAnchor.constraint(equalTo: trailingAnchor),
                stack.bottomAnchor.constraint(equalTo: bottomAnchor),
            ])
            // add 5 Percent Image Views to the stack view
            for _ in 1...5 {
                let v = PercentImageView(frame: .zero)
                stack.addArrangedSubview(v)
                v.heightAnchor.constraint(equalTo: v.widthAnchor).isActive = true
            }
        }
        
        private class PercentImageView: UIImageView {
            
            var percent: CGFloat = 0.0 {
                didSet {
                    setNeedsLayout()
                }
            }
            
            var tranparency: CGFloat = 0.5 {
                didSet {
                    setNeedsLayout()
                }
            }
            
            private let gLayer = CAGradientLayer()
            
            override init(frame: CGRect) {
                super.init(frame: frame)
                commonInit()
            }
            required init?(coder: NSCoder) {
                super.init(coder: coder)
                commonInit()
            }
            func commonInit() -> Void {
                gLayer.startPoint = CGPoint.zero
                gLayer.endPoint = CGPoint(x: 1.0, y: 0.0)
                layer.mask = gLayer
            }
            override func layoutSubviews() {
                super.layoutSubviews()
    
                // we don't want the layer's intrinsic animation
                CATransaction.begin()
                CATransaction.setDisableActions(true)
    
                gLayer.frame = bounds
                gLayer.locations = [
                    0.0, percent as NSNumber, percent as NSNumber, 1.0,
                ]
                gLayer.colors = [
                    UIColor.black.cgColor,
                    UIColor.black.cgColor,
                    UIColor.black.withAlphaComponent(tranparency).cgColor,
                    UIColor.black.withAlphaComponent(tranparency).cgColor,
                ]
                
                CATransaction.commit()
            }
        }
    
    }
    
    
    class StarRatingViewController: UIViewController {
    
        let ratingView = FiveStarRatingView()
        
        let slider = UISlider()
        let valueLabel = UILabel()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            guard let starImage = UIImage(named: "star") else {
                fatalError("Could not load image named \"star\"")
            }
            
            // add a slider and a couple labels so we can change the rating
            let minLabel = UILabel()
            let maxLabel = UILabel()
            [slider, valueLabel, minLabel, maxLabel].forEach {
                view.addSubview($0)
                $0.translatesAutoresizingMaskIntoConstraints = false
                if let v = $0 as? UILabel {
                    v.textAlignment = .center
                }
            }
            
            let g = view.safeAreaLayoutGuide
            
            NSLayoutConstraint.activate([
                valueLabel.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
                valueLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                
                slider.topAnchor.constraint(equalTo: valueLabel.bottomAnchor, constant: 8.0),
                slider.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 32.0),
                slider.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -32.0),
                
                minLabel.topAnchor.constraint(equalTo: slider.bottomAnchor, constant: 8.0),
                minLabel.centerXAnchor.constraint(equalTo: slider.leadingAnchor, constant: 0.0),
                
                maxLabel.topAnchor.constraint(equalTo: slider.bottomAnchor, constant: 8.0),
                maxLabel.centerXAnchor.constraint(equalTo: slider.trailingAnchor, constant: 0.0),
            ])
            minLabel.text = "0"
            maxLabel.text = "5"
            
            ratingView.translatesAutoresizingMaskIntoConstraints = false
            
            view.addSubview(ratingView)
    
            NSLayoutConstraint.activate([
                // constrain the rating view centered in the view
                //  300-pts wide
                //  height will be auto-set by the rating view
                ratingView.topAnchor.constraint(equalTo: minLabel.bottomAnchor, constant: 20.0),
                ratingView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                ratingView.widthAnchor.constraint(equalToConstant: 240.0),
            ])
            
            // use the star image
            ratingView.ratingImage = starImage
            
            // start at rating of 0 stars
            updateValue(0.0)
            slider.value = 0
            
            slider.addTarget(self, action: #selector(self.sliderChanged(_:)), for: .valueChanged)
        }
        
        @objc func sliderChanged(_ sender: UISlider) {
            // round the slider value to 2 decimal places
            updateValue((sender.value * 5.0).rounded(digits: 2))
        }
        
        func updateValue(_ v: Float) -> Void {
            valueLabel.text = String(format: "%.2f", v)
            ratingView.rating = CGFloat(v)
        }
        
    }
    
    extension Float {
        func rounded(digits: Int) -> Float {
            let multiplier = Float(pow(10.0, Double(digits)))
            return (self * multiplier).rounded() / multiplier
        }
    }
    

    Result:

    enter image description here

    Note that the FiveStarRatingView class is marked @IBDesignable so you can add it in Storyboard / IB and set image, amount of transparency and rating at design-time.