UIView layer's sublayers display differently/r

2019-06-07 01:17发布

问题:

I have a simple custom CALayer to create an overlaying gradient effect on my UIView. Here is the code:

class GradientLayer: CALayer {

var locations: [CGFloat]?
var origin: CGPoint?
var radius: CGFloat?
var color: CGColor?

convenience init(view: UIView, locations: [CGFloat]?, origin: CGPoint?, radius: CGFloat?, color: UIColor?) {
    self.init()
    self.locations = locations
    self.origin = origin
    self.radius = radius
    self.color = color?.CGColor
    self.frame = view.bounds
}

override func drawInContext(ctx: CGContext) {
    super.drawInContext(ctx)

    guard let locations = self.locations else { return }
    guard let origin = self.origin else { return }
    guard let radius = self.radius else { return }

    let colorSpace = CGColorGetColorSpace(color)
    let colorComponents = CGColorGetComponents(color)

    let gradient = CGGradientCreateWithColorComponents(colorSpace, colorComponents, locations, locations.count)
    CGContextDrawRadialGradient(ctx, gradient, origin, CGFloat(0), origin, radius, [.DrawsAfterEndLocation])
}
}

I initialize and set these layers here:

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    let gradient1 = GradientLayer(view: view, locations: [0.0,1.0], origin: CGPoint(x: view.frame.midX, y: view.frame.midY), radius: 100.0, color: UIColor(white: 1.0, alpha: 0.2))

    let gradient2 = GradientLayer(view: view, locations: [0.0,1.0], origin: CGPoint(x: view.frame.midX-20, y: view.frame.midY+20), radius: 160.0, color: UIColor(white: 1.0, alpha: 0.2))

    let gradient3 = GradientLayer(view: view, locations: [0.0,1.0], origin: CGPoint(x: view.frame.midX+30, y: view.frame.midY-30), radius: 300.0, color: UIColor(white: 1.0, alpha: 0.2))

    gradient1.setNeedsDisplay()
    gradient2.setNeedsDisplay()
    gradient3.setNeedsDisplay()

    view.layer.addSublayer(gradient1)
    view.layer.addSublayer(gradient2)
    view.layer.addSublayer(gradient3)
}

The view seems to display properly most of the time, but (seemingly) randomly I'll get different renderings as you'll see below. Here are some examples (the first one is what I want):

What is causing this malfunction? How do I only load the first one every time?

回答1:

You have several problems.

First off, you should think of a gradient as an array of stops, where a stop has two parts: a color and a location. You must have an equal number of colors and locations, because every stop has one of each. You can see this if, for example, you check the CGGradientCreateWithColorComponents documentation regarding the components argument:

The number of items in this array should be the product of count and the number of components in the color space.

It's a product (the result of a multiplication) because you have count stops and you need a complete set of color components for each stop.

You're not providing enough color components. Your GradientLayer could have any number of locations (and you're giving it two) but has only one color. You're getting that one color's components and passing that as the components array to CGGradientCreateWithColorComponents, but the array is too short. Swift doesn't catch this error—notice that the type of your colorComponents is UnsafePointer<CGFloat>. The Unsafe part tells you that you're in dangerous territory. (You can see the type of colorComponents by option-clicking it in Xcode.)

Since you're not providing a large enough array for components, iOS is using whatever random values happen to be in memory after the components of your one color. Those may change from run to run and are often not what you want them to be.

In fact, you shouldn't even use CGGradientCreateWithColorComponents. You should use CGGradientCreateWithColors, which takes an array of CGColor so it's not only simpler to use, but safer because it's one less UnsafePointer floating around.

Here's what GradientLayer should look like:

class RadialGradientLayer: CALayer {

    struct Stop {
        var location: CGFloat
        var color: UIColor
    }

    var stops: [Stop] { didSet { self.setNeedsDisplay() } }
    var origin: CGPoint { didSet { self.setNeedsDisplay() } }
    var radius: CGFloat { didSet { self.setNeedsDisplay() } }

    init(stops: [Stop], origin: CGPoint, radius: CGFloat) {
        self.stops = stops
        self.origin = origin
        self.radius = radius
        super.init()
        needsDisplayOnBoundsChange = true
    }

    override init(layer other: AnyObject) {
        guard let other = other as? RadialGradientLayer else { fatalError() }
        stops = other.stops
        origin = other.origin
        radius = other.radius
        super.init(layer: other)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func drawInContext(ctx: CGContext) {
        let locations = stops.map { $0.location }
        let colors = stops.map { $0.color.CGColor }

        locations.withUnsafeBufferPointer { pointer in
            let gradient = CGGradientCreateWithColors(nil, colors, pointer.baseAddress)
            CGContextDrawRadialGradient(ctx, gradient, origin, 0, origin, radius, [.DrawsAfterEndLocation])
        }
    }

}

Next problem. You're adding more gradient layers every time the system calls viewWillLayoutSubviews. It can call that function multiple times! For example, it will call it if your app supports interface rotation, or if a call comes in and iOS makes the status bar taller. (You can test that in the simulator by choosing Hardware > Toggle In-Call Status Bar.)

You need to create the gradient layers once, storing them in a property. If they have already been created, you need to update their frames and not create new layers:

class ViewController: UIViewController {

    private var gradientLayers = [RadialGradientLayer]()

    override func viewWillLayoutSubviews() {
        super.viewWillLayoutSubviews()

        if gradientLayers.isEmpty {
            createGradientLayers()
        }

        for layer in gradientLayers {
            layer.frame = view.bounds
        }
    }

    private func createGradientLayers() {
        let bounds = view.bounds
        let mid = CGPointMake(bounds.midX, bounds.midY)
        typealias Stop = RadialGradientLayer.Stop

        for (point, radius, color) in [
            (mid, 100, UIColor(white:1, alpha:0.2)),
            (CGPointMake(mid.x - 20, mid.y + 20), 160, UIColor(white:1, alpha:0.2)),
            (CGPointMake(mid.x + 30, mid.y - 30), 300, UIColor(white:1, alpha:0.2))
            ] as [(CGPoint, CGFloat, UIColor)] {

                let stops: [RadialGradientLayer.Stop] = [
                    Stop(location: 0, color: color),
                    Stop(location: 1, color: color.colorWithAlphaComponent(0))]

                let layer = RadialGradientLayer(stops: stops, origin: point, radius: radius)
                view.layer.addSublayer(layer)
                gradientLayers.append(layer)
        }
    }

}


回答2:

Your problem is the code you have written in viewWillLayoutSubviews function its is called multiple times when views loads just add a check to run it once or better yet add a check in viewdidlayoutsubviews to run it once