Rotate a Text view and its frame in SwiftUI

Viewed 3479

I can rotate the Text in SwiftUI using rotationEffect but it doesn't rotate the frame. As shown in the image, the text is rotated but the frame is still horizontal. I would like to rotate the frame too so it doesn't take up horizontal space. This is for a Mac app where I'm using HStack to prevent the Text and Circle views from overlapping when the window changes size.

vertical text

import SwiftUI

struct ContentView: View {
    var body: some View {
        HStack {
            Text("Vertical text")
                .rotationEffect(.degrees(-90))
            Circle()
        }
        .frame(width: 400, height: 300)
    }
}

One suggestion is to use ZStack. This fixes the appearance of the Text view next to the Circle but it doesn't rotate the frame of the Text view. And if ZStack is used with a resizable window then the Circle can overlap the Text view which is why I was trying to use HStack in my original example.

resizable window

struct ContentView: View {
    var body: some View {        
        ZStack(alignment: .leading) {
            Text("Vertical text")
                .rotationEffect(.degrees(-90))
            Circle()
                .padding(.leading)
        }
        .frame(minWidth: 400, minHeight: 300)
    }
}
4 Answers

Applying a fixedSize and frame size to the Text view appears to fix my problem. This also works well for windows that are resizable because the HStack prevents the Text view and Circle view from overlapping.

enter image description here

import SwiftUI

struct ContentView: View {
    var body: some View {
        HStack {
            Text("Vertical text")
                .rotationEffect(.degrees(-90))
                .fixedSize()
                .frame(width: 20, height: 180)
            Circle()
                .frame(width: 200)
        }
        .frame(width: 400, height: 300)
    }
}

Using a fixedSize() and then an explicit frame modifier is the right methodology, but you don't want to have to guess-and-check update your Text's frame every time you change the containing text. We can use a custom view modifier and the preferences system to pass the exact right size up the view hierarchy

enter image description here


struct RotatedVertical: ViewModifier {

    /// Whether the view should be rotated so its baseline ends up on the left is `true`, right if `false.`
    let baselineLeft: Bool = false

    /// A binding to a `State` variable that gets assigned to the caller's frame size.
    let frame: Binding<CGSize?>

    func body(content: Content) -> some View {
        content.overlay(GeometryReader { proxy in
            Color.clear.preference(key: TextSizeKey.self, value: proxy.size)
        })
        .onPreferenceChange(TextSizeKey.self, perform: { newSize in
            frame.wrappedValue = newSize
        })
        .fixedSize() // Calculate and fix our ideal size before rotation
        .rotationEffect(baselineLeft ? .degrees(90) : .degrees(270))
        .frame(width: frame.wrappedValue?.height, height: frame.wrappedValue?.width) // Assign the flipped sizes via the Preference system updates
    }
}

extension View {

    /// Wrap our `ViewModifier` in a convenience method
    func rotatedVertical(baselineLeft: Bool = false, frame: Binding<CGSize?>) -> some View {
        modifier(RotatedVertical(frame: frame))
    }
}

struct RotatedTextView: View {
    @State var textSize: CGSize?

    var body: some View {
        Text("Hello World")
            .rotatedVertical(frame: $textSize)
            .border(.green)
    }
}

Ok, before start making things complicated, let's try to make them simple, how about the following:

demo

var body: some View {
    ZStack(alignment: .leading) {
        Text("Vertical text")
            .rotationEffect(.degrees(-90))
        Circle()
            .padding(.leading)
    }
    .frame(width: 400, height: 300)
}

yes, it gives not real but only visual effect, however it is fast and simple and very extendable... not enough?

Building on @nteissler answer to measure the text dynamically I have the following (see comments in code for explanation)

struct VerticalRotationModifier: ViewModifier {
    @State private var contentSize = CGSize.zero
    let rotation: VerticalRotationType

    // 1) Transparent overlay captures the view's size using `ContentSizePreferenceKey`
    // 2) Setting a `.preference` triggers `.onPreferenceChange` which sets the @State contentSize
    // 3) The frame size is set based on the @State contentSize with the height and width flipped
    func body(content: Content) -> some View {
        content
            .fixedSize()
            .overlay(GeometryReader { proxy in
                Color.clear.preference(key: ContentSizePreferenceKey.self, value: proxy.size)
            })
            .onPreferenceChange(ContentSizePreferenceKey.self, perform: { newSize in
                contentSize = newSize
            })
            .rotationEffect(rotation.asAngle)
            .frame(width: contentSize.height, height: contentSize.width)
    }

    enum VerticalRotationType {
        case clockwise
        case anticlockwise
        
        var asAngle: Angle {
            switch(self) {
            case .clockwise:
                return .degrees(90)
            case .anticlockwise:
                return .degrees(-90)
            }
        }
    }
    
    private struct ContentSizePreferenceKey: PreferenceKey {
        static var defaultValue: CGSize = .zero

        static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
            value = nextValue()
        }
    }
}


struct VerticalRotationModifier_Previews: PreviewProvider {
    static var previews: some View {
        HStack {
            Text("Vertical text")
                .modifier(VerticalRotationModifier(rotation: .anticlockwise))
                .border(.green)
            Circle()
                .frame(width: 200)
        }
    }
}
Related