69

I'm trying to get my CAGradientLayers, that i'm using to create nice gradient backgrounds, to resize nicely on rotation and modal view presentation, but they will not play ball.

Here is a video I just created showing my problem: Notice the tearing on rotation.

Also please note this video was created by filming the iPhone Simulator on OS X. I have slowed down the animations in the video to highlight my issue.

Video of Problem...

Here is an Xcode project which I just created (which is the source for the app shown in the video), basically as illustrated the issue occurs on rotation and especially when views are presented modally:

Xcode Project, modally presenting views with CAGradientLayer backgrounds...

For what it's worth I understand that using:

    [[self view] setBackgroundColor:[UIColor blackColor]];

does a reasonable job of making the transitions a bit more seamless and less jarring, but if you look at the video when I, whilst currently in landscape mode, modally present a view, you will see why the above code will not help.

Any ideas what I can do to sort this out?

John

Woodstock
  • 22,184
  • 15
  • 80
  • 118

10 Answers10

185

When you create a layer (like your gradient layer), there's no view managing the layer (even when you add it as a sublayer of some view's layer). A standalone layer like this doesn't participate in the UIView animation system.

So when you update the frame of the gradient layer, the layer animates the change with its own default animation parameters. (This is called “implicit animation”.) These default parameters don't match the animation parameters used for interface rotation, so you get a weird result.

I didn't look at your project but it's trivial to reproduce your problem with this code:

@interface ViewController ()

@property (nonatomic, strong) CAGradientLayer *gradientLayer;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    self.gradientLayer = [CAGradientLayer layer];
    self.gradientLayer.colors = @[ (__bridge id)[UIColor blueColor].CGColor, (__bridge id)[UIColor blackColor].CGColor ];
    [self.view.layer addSublayer:self.gradientLayer];
}

- (void)viewDidLayoutSubviews {
    [super viewDidLayoutSubviews];
    self.gradientLayer.frame = self.view.bounds;
}

@end

Here's what that looks like, with slow motion enabled in the simulator:

bad rotation

Fortunately, this is an easy problem to fix. You need to make your gradient layer be managed by a view. You do that by creating a UIView subclass that uses a CAGradientLayer as its layer. The code is tiny:

// GradientView.h

@interface GradientView : UIView

@property (nonatomic, strong, readonly) CAGradientLayer *layer;

@end

// GradientView.m

@implementation GradientView

@dynamic layer;

+ (Class)layerClass {
    return [CAGradientLayer class];
}

@end

Then you need to change your code to use GradientView instead of CAGradientLayer. Since you're using a view now instead of a layer, you can set the autoresizing mask to keep the gradient sized to its superview, so you don't have to do anything later to handle rotation:

@interface ViewController ()

@property (nonatomic, strong) GradientView *gradientView;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    self.gradientView = [[GradientView alloc] initWithFrame:self.view.bounds];
    self.gradientView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
    self.gradientView.layer.colors = @[ (__bridge id)[UIColor blueColor].CGColor, (__bridge id)[UIColor blackColor].CGColor ];
    [self.view addSubview:self.gradientView];
}

@end

Here's the result:

good rotation

rob mayoff
  • 375,296
  • 67
  • 796
  • 848
  • What an answer. Superb thank you. - 2 quick things, 1. What is a layer to a uiview? does each view have a layer? is it like the actual component of the view that gets drawn to? and 2. In the delegate method willAnimateRotationToInterfaceOrientation:duration can the duration be set? Or is it just set by the system and available to pole? – Woodstock Jul 09 '13 at 21:46
  • 4
    Every view has its own dedicated layer. You can create additional layers, like you were doing with your gradient layer. Read [“View Architecture Fundamentals” in the *View Programming Guide for iOS*](http://developer.apple.com/library/ios/documentation/windowsviews/conceptual/viewpg_iphoneos/WindowsandViews/WindowsandViews.html#//apple_ref/doc/uid/TP40009503-CH2-SW9). You can't change the duration of the autorotation animation. – rob mayoff Jul 09 '13 at 21:48
  • Wondeful, thanks for all the answers, when is duration passed as an input parameter then? – Woodstock Jul 09 '13 at 21:51
  • I don't understand your question. – rob mayoff Jul 09 '13 at 21:51
  • With this delegate method: willAnimateRotationToInterfaceOrientation:duration -- why is duration passed when this is called by the system? – Woodstock Jul 09 '13 at 21:52
  • The system passes you the duration in that message in case you need to do something in sync with the animation. Note that it doesn't tell you the animation curve, so it's not enough information to exactly match the rotation animation. – rob mayoff Jul 09 '13 at 21:53
  • 17
    May I recommend this answer be edited to work with Swift as well? It's a fantastic answer that should be in both languages for future reference. – Andy Ibanez Oct 10 '14 at 19:20
  • 1
    You can also achieve the same thing by setting the `layers.colors` attribute directly on the UIView your adding the gradient to. This saves the need for a sublayer. Just set the class attribute in your storyboard to `GradientView.h` then call `[(CAGradientLayer *)[[self myGradientView] layer] setColors:...` (probably will not work for `self.view` though) – Johann Burgess Apr 27 '16 at 13:01
  • How to avoid the warning on the `layer` property: "Auto property synthesis will not synthesize property 'layer'; it will be implemented by its superclass, use @dynamic to ackmowledge intention"? – meaning-matters May 05 '16 at 17:53
  • Not sure, if I need to create GradientView. Everything has been fixed by just using your viewDidLayoutSubviews implementation. – Eugene Brusov Nov 27 '17 at 09:26
  • I've just tried this and I couldn't do it with any other method on any other place. This is a only solution for now. Thanks! – Srdjan Dec 12 '18 at 10:22
  • This is the best and should be the only correct answer. it's been a week I'm looking for a solution but all what I found was either too complicated or not working. Thanks millions times – Firas KADHUM Aug 27 '22 at 09:19
47

The best part about @rob's answer is that the view controls the layer for you. Here is the Swift code that properly overrides the layer class and sets the gradient.

import UIKit

class GradientView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }

    private func setupView() {
        autoresizingMask = [.flexibleWidth, .flexibleHeight]

        guard let theLayer = self.layer as? CAGradientLayer else {
            return;
        }

        theLayer.colors = [UIColor.whiteColor.cgColor, UIColor.lightGrayColor.cgColor]
        theLayer.locations = [0.0, 1.0]
        theLayer.frame = self.bounds
    }

    override class var layerClass: AnyClass {
        return CAGradientLayer.self
    }
}

You can then add the view in two lines wherever you want.

override func viewDidLoad() {
    super.viewDidLoad()

    let gradientView = GradientView(frame: self.view.bounds)
    self.view.insertSubview(gradientView, atIndex: 0)
}
proxpero
  • 2,206
  • 1
  • 16
  • 16
Kevin Marlow
  • 861
  • 7
  • 11
  • instead of using- theLayer.locations = [0.0, 1.0], I used a startPoint and end Point like so- theLayer.startPoint = CGPoint(x: 0.5, y: 0.0); theLayer.endPoint = CGPoint(x: 0.5, y: 1.0) – Lance Samaria Oct 25 '18 at 09:03
  • 2
    Mister, You have all my respect, I deal about 2 days on this, and I finally found you <3 – Romain Sickenberg Dec 03 '18 at 20:42
  • This is great, however, I am using a view within the master view and I am doing it programmatically and using constraints. I just cannot get the gradient in that subview. It works really well on the masterview. Any help would be gratefully received – user3194306 Jan 17 '21 at 11:26
  • @user3194306 you might need to provide code so we can better understand what exactly is happening. There is a possibility it is how you are using constraints. – Kevin Marlow Mar 23 '21 at 04:00
6

My swift version:

import UIKit

class GradientView: UIView {

    override class func layerClass() -> AnyClass {
        return CAGradientLayer.self
    }

    func gradientWithColors(firstColor : UIColor, _ secondColor : UIColor) {

        let deviceScale = UIScreen.mainScreen().scale
        let gradientLayer = CAGradientLayer()
        gradientLayer.frame = CGRectMake(0.0, 0.0, self.frame.size.width * deviceScale, self.frame.size.height * deviceScale)
        gradientLayer.colors = [ firstColor.CGColor, secondColor.CGColor ]

        self.layer.insertSublayer(gradientLayer, atIndex: 0)
    }
}

Note that I also had to use the device scale to calculate the frame size - to get correct auto-sizing during orientation changes (with auto-layout).

  1. In Interface Builder, I added a UIView and changed its class to GradientView (the class shown above).
  2. I then created an outlet for it (myGradientView).
  3. Finally, In the view controller I added:

    override func viewDidLayoutSubviews() {
        self.myGradientView.gradientWithColors(UIColor.whiteColor(), UIColor.blueColor())
    }
    

Note that the gradient view is created in a "layoutSubviews" method, since we need a finalized frame to create the gradient layer.

Dan Beaulieu
  • 19,406
  • 19
  • 101
  • 135
GK100
  • 3,664
  • 1
  • 26
  • 19
  • 3
    Following the code in this answer, there is a very noticeable animation on rotation. Instead, you should let the view handle your gradient by overriding the view's layer, not inserting your own sublayer. – Moshe Carmeli Mar 14 '16 at 18:11
  • This adds a new gradient layer on every viewDidLayoutSubviews call, this could cause a memory issue. – SamB Feb 04 '18 at 08:48
  • I struggled to get my gradient view have correct size when adding text to a popup view. Just using the viewDidLayoutSubviews() to setup the gradient instead of viewDidLoad() solved my problem! No subclass needed. But to get correct gradient when rotating device I had to use the subview. I ended up using Dan's solution without the Rect. – Paintoshi May 24 '18 at 05:50
  • There is no point in subclassing to GradientView if you're going to then insert sublayers :/ – Hayden Crocker Oct 10 '18 at 09:25
  • what do you want to do with this code? You return `CAGradientLayer` type and then trying to add the second gradient – Gargo Feb 27 '23 at 06:17
3

It will look better when you insert this piece of code and remove the willAnimateRotationToInterfaceOrientation:duration: implementation.

- (void)viewWillLayoutSubviews
{
    [[[self.view.layer sublayers] objectAtIndex:0] setFrame:self.view.bounds];    
}

This is however not very elegant. In a real application you should subclass UIView to create a gradient view. In this custom view you can override layerClass so that it is backed by a gradient layer:

+ (Class)layerClass
{
  return [CAGradientLayer class];
}

Also implement layoutSubviews to handle when the bounds of the view changes.

When creating this background view use autoresizing masks so that the bounds automatically adjust on interface rotations.

Felix
  • 35,354
  • 13
  • 96
  • 143
  • Interesting stuff there... So willAnimateRotationToInterfaceOrientation:duration -- when should that be used? Isn't viewWillLayoutSubviews called after rotation and as such won't animate the rotation? – Woodstock Jul 09 '13 at 20:07
  • 1
    `viewWillLayoutSubviews` is called inside the rotation animation block. Unfortunately, standalone layers don't participate in the `UIView` animation system, so this doesn't fix the problem. – rob mayoff Jul 09 '13 at 21:38
2

Complete Swift version. Set viewFrame from the viewController that owns this view in viewDidLayoutSubviews

import UIKit

class MainView: UIView {

    let topColor = UIColor(red: 146.0/255.0, green: 141.0/255.0, blue: 171.0/255.0, alpha: 1.0).CGColor
    let bottomColor = UIColor(red: 31.0/255.0, green: 28.0/255.0, blue: 44.0/255.0, alpha: 1.0).CGColor

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupGradient()
    }

    override class func layerClass() -> AnyClass {
        return CAGradientLayer.self
    }

    var gradientLayer: CAGradientLayer {
        return layer as! CAGradientLayer
    }

    var viewFrame: CGRect! {
        didSet {
            self.bounds = viewFrame
        }
    }

    private func setupGradient() {
        gradientLayer.colors = [topColor, bottomColor]
    }
}
SmileBot
  • 19,393
  • 7
  • 65
  • 62
2

Info

  • Use as one line solution
  • Replacing gradient when you add it to the view again (to use in reusables)
  • Automatically transiting
  • Automatically removing

Details

Swift 3.1, xCode 8.3.3

Solution

import UIKit

extension UIView {

    func addGradient(colors: [UIColor], locations: [NSNumber]) {
        addSubview(ViewWithGradient(addTo: self, colors: colors, locations: locations))
    }
}

class ViewWithGradient: UIView {

    private var gradient = CAGradientLayer()

    init(addTo parentView: UIView, colors: [UIColor], locations: [NSNumber]){

        super.init(frame: CGRect(x: 0, y: 0, width: 1, height: 2))
        restorationIdentifier = "__ViewWithGradient"

        for subView in parentView.subviews {
            if let subView = subView as? ViewWithGradient {
                if subView.restorationIdentifier == restorationIdentifier {
                    subView.removeFromSuperview()
                    break
                }
            }
        }

        let cgColors = colors.map { (color) -> CGColor in
            return color.cgColor
        }

        gradient.frame = parentView.frame
        gradient.colors = cgColors
        gradient.locations = locations
        backgroundColor = .clear

        parentView.addSubview(self)
        parentView.layer.insertSublayer(gradient, at: 0)
        parentView.backgroundColor = .clear
        autoresizingMask = [.flexibleWidth, .flexibleHeight]

        clipsToBounds = true
        parentView.layer.masksToBounds = true

    }

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

    override func layoutSubviews() {
        super.layoutSubviews()

        if let parentView = superview {
            gradient.frame = parentView.bounds
        }
    }

    override func removeFromSuperview() {
        super.removeFromSuperview()
        gradient.removeFromSuperlayer()
    }
}

Usage

viewWithGradient.addGradient(colors: [.blue, .green, .orange], locations: [0.1, 0.3, 1.0])

Using StoryBoard

ViewController

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var viewWithGradient: UIView!

    override func viewDidLoad() {
        super.viewDidLoad()
        viewWithGradient.addGradient(colors: [.blue, .green, .orange], locations: [0.1, 0.3, 1.0])
    }
}

StoryBoard

<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16F73" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
    <device id="retina4_7" orientation="portrait">
        <adaptation id="fullscreen"/>
    </device>
    <dependencies>
        <deployment identifier="iOS"/>
        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
        <capability name="Constraints to layout margins" minToolsVersion="6.0"/>
        <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
    </dependencies>
    <scenes>
        <!--View Controller-->
        <scene sceneID="tne-QT-ifu">
            <objects>
                <viewController id="BYZ-38-t0r" customClass="ViewController" customModule="stackoverflow_17555986" customModuleProvider="target" sceneMemberID="viewController">
                    <layoutGuides>
                        <viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
                        <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
                    </layoutGuides>
                    <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
                        <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
                        <subviews>
                            <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="uii-31-sl9">
                                <rect key="frame" x="66" y="70" width="243" height="547"/>
                                <color key="backgroundColor" white="0.66666666666666663" alpha="1" colorSpace="calibratedWhite"/>
                            </view>
                        </subviews>
                        <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
                        <constraints>
                            <constraint firstItem="wfy-db-euE" firstAttribute="top" secondItem="uii-31-sl9" secondAttribute="bottom" constant="50" id="a7J-Hq-IIq"/>
                            <constraint firstAttribute="trailingMargin" secondItem="uii-31-sl9" secondAttribute="trailing" constant="50" id="i9v-hq-4tD"/>
                            <constraint firstItem="uii-31-sl9" firstAttribute="top" secondItem="y3c-jy-aDJ" secondAttribute="bottom" constant="50" id="wlO-83-8FY"/>
                            <constraint firstItem="uii-31-sl9" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leadingMargin" constant="50" id="zb6-EH-j6p"/>
                        </constraints>
                    </view>
                    <connections>
                        <outlet property="viewWithGradient" destination="uii-31-sl9" id="FWB-7A-MaH"/>
                    </connections>
                </viewController>
                <placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
            </objects>
        </scene>
    </scenes>
</document>

Programmatically

import UIKit

class ViewController2: UIViewController {

    @IBOutlet weak var viewWithGradient: UIView!

    override func viewDidLoad() {
        super.viewDidLoad()
        let viewWithGradient = UIView(frame: CGRect(x: 10, y: 20, width: 30, height: 40))
        view.addSubview(viewWithGradient)


        viewWithGradient.translatesAutoresizingMaskIntoConstraints = false
        let constant:CGFloat = 50.0

        NSLayoutConstraint(item: viewWithGradient, attribute: .leading, relatedBy: .equal, toItem: view, attribute: .leadingMargin, multiplier: 1.0, constant: constant).isActive = true
        NSLayoutConstraint(item: viewWithGradient, attribute: .trailing, relatedBy: .equal, toItem: view, attribute: .trailingMargin
                    , multiplier: 1.0, constant: -1*constant).isActive = true
        NSLayoutConstraint(item: viewWithGradient, attribute: .bottom, relatedBy: .equal, toItem: view, attribute: .bottomMargin
            , multiplier: 1.0, constant: -1*constant).isActive = true
        NSLayoutConstraint(item: viewWithGradient, attribute: .top, relatedBy: .equal, toItem: view, attribute: .topMargin
            , multiplier: 1.0, constant: constant).isActive = true

        viewWithGradient.addGradient(colors: [.blue, .green, .orange], locations: [0.1, 0.3, 1.0])
    }
}
Vasily Bodnarchuk
  • 24,482
  • 9
  • 132
  • 127
1

Another swift version - which is not using drawRect.

class UIGradientView: UIView {
    override class func layerClass() -> AnyClass {
        return CAGradientLayer.self
    }

    var gradientLayer: CAGradientLayer {
        return layer as! CAGradientLayer
    }

    func setGradientBackground(colors: [UIColor], startPoint: CGPoint = CGPoint(x: 0.5, y: 0), endPoint: CGPoint = CGPoint(x: 0.5, y: 1)) {
        gradientLayer.startPoint = startPoint
        gradientLayer.endPoint = endPoint
        gradientLayer.colors = colors.map({ (color) -> CGColor in return color.CGColor })
    }
}

In controller I just call:

gradientView.setGradientBackground([UIColor.grayColor(), UIColor.whiteColor()])
Dan
  • 106
  • 4
  • This worked great also with a tableView background and screen rotations: let gradientView: gradientView = gradientView(), gradientView.setGradientBackground(colors: [UIColor.white, UIColor.black]), tableView.backgroundView = gradientView – Paintoshi May 24 '18 at 06:01
0

Personally, I prefer to keep everything self-contained within the view subclass.

Here's my Swift implementation:

            import UIKit

            @IBDesignable
            class GradientBackdropView: UIView {

                @IBInspectable var startColor: UIColor=UIColor.whiteColor()
                @IBInspectable var endColor: UIColor=UIColor.whiteColor()
                @IBInspectable var intermediateColor: UIColor=UIColor.whiteColor()

                var gradientLayer: CAGradientLayer?

                // Only override drawRect: if you perform custom drawing.
                // An empty implementation adversely affects performance during animation.
                override func drawRect(rect: CGRect) {
                    // Drawing code
                    super.drawRect(rect)

                    if gradientLayer == nil {
                        self.addGradientLayer(rect: rect)
                    } else {
                        gradientLayer?.removeFromSuperlayer()
                        gradientLayer=nil
                        self.addGradientLayer(rect: rect)
                    }
                }


                override func layoutSubviews() {
                    super.layoutSubviews()

                    if gradientLayer == nil {
                        self.addGradientLayer(rect: self.bounds)
                    } else {
                        gradientLayer?.removeFromSuperlayer()
                        gradientLayer=nil
                        self.addGradientLayer(rect: self.bounds)
                    }
                }


                func addGradientLayer(rect rect:CGRect) {
                    gradientLayer=CAGradientLayer()

                    gradientLayer?.frame=self.bounds

                    gradientLayer?.colors=[startColor.CGColor,intermediateColor.CGColor,endColor.CGColor]

                    gradientLayer?.startPoint=CGPointMake(0.0, 1.0)
                    gradientLayer?.endPoint=CGPointMake(0.0, 0.0)

                    gradientLayer?.locations=[NSNumber(float: 0.1),NSNumber(float: 0.5),NSNumber(float: 1.0)]

                    self.layer.insertSublayer(gradientLayer!, atIndex: 0)

                    gradientLayer?.transform=self.layer.transform
                }
            }
0

You can use this from storyboard, xib or code. You can change the colors dynamically later (I needed that for my case)

Adding a complete copy-pastable one here:

import UIKit

class GradientView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupView()
    }

    private func setupView() {
        autoresizingMask = [.flexibleWidth, .flexibleHeight]
    }

    override class var layerClass: AnyClass {
        return CAGradientLayer.self
    }
}

extension GradientView {
    func setVerticalGradientBackground(colors: [CGColor], locations: [CGFloat] = [0, 1]) {
        setGradientBackground(colors: colors, locations: locations, startPoint: .init(x: 0.5, y: 0), endPoint: .init(x: 0.5, y: 1))
    }

    func setHorizontalGradientBackground(colors: [CGColor], locations: [CGFloat] = [0, 1]) {
        setGradientBackground(colors: colors, locations: locations, startPoint: .init(x: 0, y: 0.5), endPoint: .init(x: 1, y: 0.5))
    }

    func setGradientBackground(colors: [CGColor],
                               locations: [CGFloat],
                               startPoint: CGPoint,
                               endPoint: CGPoint) {

        guard let gradientLayer = self.layer as? CAGradientLayer else {
            return
        }

        gradientLayer.colors = colors
        gradientLayer.locations = locations.map { $0 as NSNumber }
        gradientLayer.startPoint = startPoint
        gradientLayer.endPoint = endPoint
        gradientLayer.frame = bounds
    }
}
Okhan Okbay
  • 1,374
  • 12
  • 26
0

Easy way. You can add a gradient layer each time when your view changes its size:

class YourVC: UIViewController {
...
override func viewDidLoad() {
    super.viewDidLoad()
    yourView.addObserver(self, forKeyPath: "bounds", options: [], context: nil)
}
...
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if (object as? NSObject == yourView && keyPath == "bounds") {
        //remove and add again your gradient layer
    }
}
...