UIKit rotate view 90 degrees while keeping the bounds to edge

441 Views Asked by At

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.

1

There are 1 best solutions below

7
DonMag On BEST ANSWER

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.