iosswiftuiviewuikitcgaffinetransform

UIKit rotate view 90 degrees while keeping the bounds to edge


Update:

I have since tried setting my layer anchor paint to (0,0) and translate it back to frame (0,0) after rotation using this tutorial.

This, however, still doesn't address the early wrapping issue. See below. Setting the content inset on the right side (bottom side) does not work.

    textView.frame = CGRect(x: 0, y: 0, width: view.bounds.height, height: view.bounds.width)
    print(textView.frame)
    textView.setAnchorPoint(CGPoint(x: 0, y: 0))
    
    textView.transform = CGAffineTransform(rotationAngle: (CGFloat)(Double.pi/2));
    print(textView.frame)
    textView.transform = textView.transform.translatedBy(x: 0, y: -(view.bounds.width))
    
    print(textView.frame)
    textView.contentInset = UIEdgeInsets(top: 0, left: height, bottom: 0, right: 0)
    

enter image description here

Original question: I want to rotate the only UIView in subview clockwise by 90 degrees while keeping its bounds to edges of the screen, that is, below the Navigation Bar and above the Tab Bar and in between two sides.

Normally there are two ways to do this, either set translatesAutoresizingMaskIntoConstraints to true and set subview.frame = view.bounds

or set translatesAutoresizingMaskIntoConstraints to false and add four anchors constraints (top, bottom,leading, trailing) to view's four anchor constrains.

This is what it usually will do. enter image description here

However, if I were to rotate the view while keeping its bound like before, like below, how would I do that?

enter image description here

Here's my current code to rotate a UITextView 90 degrees clockwise. I don't have a tab bar in this case but it shouldn't matter. Since before the textview grows towards the bottom, after rotation the textview should grow towards the left side. The problem is, it's not bound to the edge I showed, it is behind the nav bar.

var textView = UITextView()
view.addSubview(textView)
textView.translatesAutoresizingMaskIntoConstraints = true
textView.transform = CGAffineTransform(rotationAngle: (CGFloat)(Double.pi/2));
textView.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: view.bounds.height)

enter image description here

Here it completely disappears if I rotate it after setting the frames

enter image description here

I also tried adding arbitrary value to the textView's y frame like so

textView.frame = CGRect(x: 0, y: 100, width: view.bounds.width, height: view.bounds.height)

but the result is that words get wrapped before they reach the bottom edge.

enter image description here

I also tried adding constrains by anchor and setting translateautoresizingmaskintoconstraints to false

 constraints.append(contentsOf: [
            textView.topAnchor.constraint(equalTo: view.topAnchor),
            textView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            textView.trailingAnchor.constraint(equalTo: view.trailingAnchor)

but the result is still a white screen.

Besides what I showed, I experimented with a lot of things, adding bit of value here and there, but they all are kind of a hack and doesn't really scale. For example, if the device gets rotated to landscape mode, the entire view gets screwed up again.

So, my question is, what is the correct, scalable way to do this? I want to have this rotated textview that is able to grow(scroll) towards the left and correctly resized on any device height and any orientation.

I know this could be related to anchor point. But since I want my view to actually bound to edges and not just display its content like an usual rotated UIImage. What are the steps to achieve that?

Thank you.


Solution

  • We need to embed the UITextView in a UIView "container" ... constraining it to that container ... and then rotate the container view.

    Because the textView will continue to have a "normal" rotation relative to its superview, it will behave as desired.

    Quick example:

    class ViewController: UIViewController {
        
        let textView = UITextView()
        let containerView = UIView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            textView.translatesAutoresizingMaskIntoConstraints = false
            
            // we're going to explicitly set the container view's frame
            containerView.translatesAutoresizingMaskIntoConstraints = true
            
            containerView.addSubview(textView)
            view.addSubview(containerView)
            
            // we'll inset the textView by 8-points on all sides
            //  so we can see that it's inside the container view
            
            // avoid auto-layout error/warning messages
            let cTrailing = textView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8.0)
            cTrailing.priority = .required - 1
            
            let cBottom = textView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -8.0)
            cBottom.priority = .required - 1
            
            NSLayoutConstraint.activate([
                
                textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 8.0),
                textView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8.0),
                
                // activate trailing and bottom constraints
                cTrailing, cBottom,
                
            ])
            
            textView.font = .systemFont(ofSize: 32.0, weight: .regular)
            
            textView.text = "The quick red fox jumps over the lazy brown dog, and then goes to the kitchen to get some dinner."
            
            // so we can see the framing
            textView.backgroundColor = .yellow
            containerView.backgroundColor = .systemBlue
        }
        
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
            
            // inset the container view frame by 40
            //  leaving some empty space around it
            //  so we can tap the view
            containerView.frame = view.frame.insetBy(dx: 40.0, dy: 40.0)
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            // end editing if textView is being edited
            view.endEditing(true)
            
            // if container view is NOT rotated
            if containerView.transform == .identity {
                // rotate it
                containerView.transform = CGAffineTransform(rotationAngle: (CGFloat)(Double.pi/2));
            } else {
                // set it back to NOT rotated
                containerView.transform = .identity
            }
        }
        
    }
    

    Output:

    enter image description here

    tapping anywhere white (so, tapping the controller's view instead of the container or the textView itself) will toggle rotated/non-rotated:

    enter image description here

    Edit - responding to comment...

    The reason we have to work with the frame is because of the way .transform works.

    When we apply a .transform it changes the frame of the view, but not its bounds.

    Take a look at this quick example:

    class ExampleViewController: UIViewController {
        
        let greenLabel = UILabel()
        let yellowLabel = UILabel()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            greenLabel.translatesAutoresizingMaskIntoConstraints = false
            yellowLabel.translatesAutoresizingMaskIntoConstraints = false
    
            view.addSubview(greenLabel)
            view.addSubview(yellowLabel)
    
            let g = view.safeAreaLayoutGuide
            
            NSLayoutConstraint.activate([
                
                yellowLabel.widthAnchor.constraint(equalToConstant: 200.0),
                yellowLabel.heightAnchor.constraint(equalToConstant: 80.0),
                yellowLabel.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                yellowLabel.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                
                greenLabel.topAnchor.constraint(equalTo: yellowLabel.topAnchor),
                greenLabel.leadingAnchor.constraint(equalTo: yellowLabel.leadingAnchor),
                greenLabel.trailingAnchor.constraint(equalTo: yellowLabel.trailingAnchor),
                greenLabel.bottomAnchor.constraint(equalTo: yellowLabel.bottomAnchor),
    
            ])
            
            yellowLabel.text = "Yellow"
            greenLabel.text = "Green"
            
            yellowLabel.textAlignment = .center
            greenLabel.textAlignment = .center
    
            yellowLabel.backgroundColor = .yellow.withAlphaComponent(0.80)
            greenLabel.backgroundColor = .green
            
            // we'll give the green label a larger, red font
            greenLabel.font = .systemFont(ofSize: 48.0, weight: .bold)
            greenLabel.textColor = .systemRed
            
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            
            // if yellow label is NOT rotated
            if yellowLabel.transform == .identity {
                // rotate it
                yellowLabel.transform = CGAffineTransform(rotationAngle: (CGFloat)(Double.pi/2));
            } else {
                // set it back to NOT rotated
                yellowLabel.transform = .identity
            }
            
            print("Green  - frame: \(greenLabel.frame) bounds: \(greenLabel.bounds)")
            print("Yellow - frame: \(yellowLabel.frame) bounds: \(yellowLabel.bounds)")
    
        }
    
    }
    

    We've created two labels, with the yellow label on top of the green label, and the green label constrained top/leading/trailing/bottom to the yellow label.

    Notice that when we apply a rotation transform to the yellow label, the green label does not change.

    enter image description here

    If you look at the debug console output, you'll see that the yellow label's frame changes, but its bounds stays the same:

    // not rotated
    Green  - frame: (87.5, 303.5, 200.0, 80.0) bounds: (0.0, 0.0, 200.0, 80.0)
    Yellow - frame: (87.5, 303.5, 200.0, 80.0) bounds: (0.0, 0.0, 200.0, 80.0)
    // rotated
    Green  - frame: (87.5, 303.5, 200.0, 80.0) bounds: (0.0, 0.0, 200.0, 80.0)
    Yellow - frame: (147.5, 243.5, 80.0, 200.0) bounds: (0.0, 0.0, 200.0, 80.0)
    

    So... to make use of auto-layout / constraints, we want to create a custom UIView subclass, which has the "container" view and the text view. Something like this:

    class RotatableTextView: UIView {
        
        public var containerInset: CGFloat = 0.0 { didSet { setNeedsLayout() } }
        
        public var textViewInset: CGFloat = 0.0 {
            didSet {
                tvConstraints[0].constant = textViewInset
                tvConstraints[1].constant = textViewInset
                tvConstraints[2].constant = -textViewInset
                tvConstraints[3].constant = -textViewInset
            }
        }
        
        public let textView = UITextView()
        
        public let containerView = UIView()
        
        private var tvConstraints: [NSLayoutConstraint] = []
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
            commonInit()
        }
        private func commonInit() {
            
            textView.translatesAutoresizingMaskIntoConstraints = false
            
            // we're going to explicitly set the container view's frame
            containerView.translatesAutoresizingMaskIntoConstraints = true
            
            containerView.addSubview(textView)
            addSubview(containerView)
            
            // avoid auto-layout error/warning messages
            var c: NSLayoutConstraint!
            
            c = textView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: textViewInset)
            tvConstraints.append(c)
            
            c = textView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: textViewInset)
            tvConstraints.append(c)
            
            c = textView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -textViewInset)
            c.priority = .required - 1
            tvConstraints.append(c)
            
            c = textView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -textViewInset)
            c.priority = .required - 1
            tvConstraints.append(c)
            
            NSLayoutConstraint.activate(tvConstraints)
                    
        }
        func toggleRotation() {
            // if container view is NOT rotated
            if containerView.transform == .identity {
                // rotate it
                containerView.transform = CGAffineTransform(rotationAngle: (CGFloat)(Double.pi/2));
            } else {
                // set it back to NOT rotated
                containerView.transform = .identity
            }
        }
        override func layoutSubviews() {
            super.layoutSubviews()
            var r = CGRect(origin: .zero, size: frame.size)
            containerView.frame = r.insetBy(dx: containerInset, dy: containerInset)
        }
        
    }
    

    Now, in our controller, we can use constraints on our custom view like we always do:

    class ViewController: UIViewController {
        
        let rotTextView = RotatableTextView()
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            rotTextView.translatesAutoresizingMaskIntoConstraints = false
            
            view.addSubview(rotTextView)
            
            // we'll inset the custom view on all sides
            //  so we can tap on the "root" view to toggle the rotation
    
            let inset: CGFloat = 20.0
            
            let g = view.safeAreaLayoutGuide
            
            NSLayoutConstraint.activate([
                
                rotTextView.topAnchor.constraint(equalTo: g.topAnchor, constant: inset),
                rotTextView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: inset),
                rotTextView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -inset),
                rotTextView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -inset),
                
            ])
    
            // let's use a 32-point font
            rotTextView.textView.font = .systemFont(ofSize: 32.0, weight: .regular)
            
            // give it some initial text
            rotTextView.textView.text = "The quick red fox jumps over the lazy brown dog, and then goes to the kitchen to get some dinner."
            
            // if we want to inset either the container or the text view
            //rotTextView.containerInset = 8.0
            //rotTextView.textViewInset = 4.0
            
            // so we can see the framing if insets are > 0
            //  if both insets are 0, we won't see these, so they don't need to be set
            //rotTextView.backgroundColor = .systemBlue
            //rotTextView.containerView.backgroundColor = .systemYellow
            
            // let's set the text view background color to light-cyan
            //  so we can see its frame
            rotTextView.textView.backgroundColor = UIColor(red: 0.75, green: 1.0, blue: 1.0, alpha: 1.0)
            
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            
            // end editing if textView is being edited
            view.endEditing(true)
            
            rotTextView.toggleRotation()
            
        }
        
    }
    

    Note that this is just Example Code, but it should get you on your way.