Swift
Swift

Reputation: 1182

How to show String of array text in curve shape in SwiftUI?

I need array of text should scroll in curve shape like below:

enter image description here

Code: but with this code i can create green colour curve shape view but how to show array of text in curve shape ? please help me to achieve the output.

struct HomeNew: View {
    
    @State private var stringArray = ["Dashboard", "Attendance", "Feed", "Time table"]
    
    var body: some View {
        ZStack{
            Color.white
                .ignoresSafeArea()
            
            VStack{
                ZStack {
                    Color.appGreen2
                    Spacer()
                    ScrollView(.horizontal, showsIndicators: false) {
                        HStack(spacing: 10) {
                            ForEach(stringArray, id: \.self) { item in
                                Text(item)
                                    .foregroundColor(.white)
                                    .padding()
                            }
                        }
                        .padding(.horizontal, 10)
                    }
                }
                .frame(height: 160)
                .clipShape(TopProtractorCurveShape())
            }
        }
    }
}

struct TopProtractorCurveShape: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        
        // Starting from the bottom-left corner
        path.move(to: CGPoint(x: 0, y: rect.height))
        
        // Drawing line to the top-left corner
        path.addLine(to: CGPoint(x: 0, y: rect.height * 0.4))
        
        // Drawing the protractor-like curve
        path.addQuadCurve(to: CGPoint(x: rect.width, y: rect.height * 0.4),
                          control: CGPoint(x: rect.width / 2, y: rect.height * 0.2))
        
        // Drawing line to the bottom-right corner
        path.addLine(to: CGPoint(x: rect.width, y: rect.height))
        
        return path
    }
}

o/p: My output string array shows in line, But i need like above screen shot curve shape text with scrolling. How to write code for that? can we get it by writing SwiftUI coding? or do we need to use any framework in swiftui? if it is there please share.

enter image description here

Upvotes: -1

Views: 191

Answers (2)

Benzy Neez
Benzy Neez

Reputation: 21730

It seems that what you are wanting to do is to convert the x-displacement from scrolling into an angle of rotation. Also, you probably want to use curved text for the labels.

  • For the curved text, a SwiftUI solution can be found in the answer to SwiftUI: How to have equal spacing between letters in a curved text view? (it was my answer).

  • If the screen width is known then the arc radius for the curved text can be computed using pythagoras and trigonometry. A GeometryReader can be used to measure the size of the screen.

  • If you want to have equal distance between the labels, as opposed to each of the labels occupying an equal space (= equal arc), then it works well to use an HStack with hidden labels that act as placeholders.

  • The curved labels are then shown as an overlay over each of the hidden labels.

  • The angle of rotation is applied by using a y-offset equal to the arc radius, then a rotationEffect is applied, then the y-offset is undone again. The order of modifiers is very important here.

  • An x-offset is also needed, to compensate for the scroll displacement. This requires knowing the distance from the label to the middle of the screen. Another GeometryReader around each label can be used to determine this distance.

  • To achieve scrolling, you can put the HStack inside a ScrollView as you were doing before. However, I found it difficult to implement sticky positioning around the center of the view.

  • As an alternative to using a ScrollView, an x-offset can be applied to the HStack and a DragGesture can be used to change the offset.

  • When a drag ends, the label that is nearest to the center of the view becomes the selected label. To give sticky positioning, an adjustment to the x-offset can be applied.

Here is how it all comes together:

struct HomeNew: View {
    let stringArray = ["Dashboard", "Attendance", "Feed", "Time table"]
    let backgroundHeight: CGFloat = 160
    let labelSpacing: CGFloat = 40
    @State private var selectedIndex = 0
    @State private var xOffset = CGFloat.zero
    @GestureState private var dragOffset = CGFloat.zero

    var body: some View {
        GeometryReader { outer in
            let w = outer.size.width
            let halfWidth = w / 2
            let curveHeight = backgroundHeight * 0.1
            let slopeLen = sqrt((halfWidth * halfWidth) + (curveHeight * curveHeight))
            let arcRadius = (slopeLen * slopeLen) / (2 * curveHeight)
            let arcAngle = 4 * asin((slopeLen / 2) / arcRadius)
            HStack(spacing: labelSpacing) {
                ForEach(Array(stringArray.enumerated()), id: \.offset) { index, item in
                    Text(item)
                        .lineLimit(1)
                        .fixedSize()
                        .hidden()
                        .overlay {
                            GeometryReader { proxy in
                                let midX = proxy.frame(in: .global).midX
                                let offsetFromMiddle = midX - (w / 2)

                                // https://stackoverflow.com/a/77280669/20386264
                                CurvedText(string: item, radius: arcRadius)
                                    .foregroundStyle(.white)
                                    .offset(y: -arcRadius)
                                    .rotationEffect(.radians((offsetFromMiddle / w) * arcAngle))
                                    .offset(x: -offsetFromMiddle, y: arcRadius)
                                    .onAppear {
                                        if selectedIndex == index {
                                            xOffset = -offsetFromMiddle
                                        }
                                    }
                                    .onChange(of: dragOffset) { oldVal, newVal in
                                        if newVal == 0 {
                                            let halfWidth = (proxy.size.width + labelSpacing) / 2
                                            if (abs(offsetFromMiddle) < halfWidth) ||
                                                (index == 0 && offsetFromMiddle > 0) ||
                                                (index == stringArray.count - 1 && offsetFromMiddle < 0) {
                                                selectedIndex = index
                                                xOffset -= offsetFromMiddle
                                            }
                                        }
                                    }
                            }
                        }
                }
            }
            .offset(x: xOffset + dragOffset)
            .animation(.easeInOut, value: xOffset)
            .gesture(
                DragGesture(minimumDistance: 0)
                    .updating($dragOffset) { val, state, trans in
                        state = val.translation.width
                    }
                    .onEnded { val in
                        xOffset += val.translation.width
                    }
            )
            .padding(.top, curveHeight + 36)
            .frame(width: w)
        }
        .frame(height: backgroundHeight, alignment: .top)
        .background(.green) //appGreen2
        .clipShape(TopProtractorCurveShape())
        .ignoresSafeArea()
    }
}

Animation

Upvotes: 4

Sweeper
Sweeper

Reputation: 273540

Assuming you want the texts to rotate as the scroll view is scrolled, this should be a scrollTransition.

rotationEffect can't rotate the view about the centre of the "imaginary circle", but we can approximate this effect by offsetting the text downwards in addition to a rotationEffect about its top.

ZStack {
    Color.green
    VStack {
        Spacer()
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 0) {
                ForEach(stringArray, id: \.self) { item in
                    Text(item)
                        .foregroundColor(.white)
                        .containerRelativeFrame(.horizontal, count: 3, spacing: 10.0)
                        .padding(.bottom, 20)
                        .scrollTransition(.interactive.threshold(.centered)) { content, phase in
                            // I empirically found these magic numbers to create a
                            // *reasonably* convincing effect.
                            // In reality I don't think this should be a linear relationship, 
                            // but I'm too bad at maths to figure out what the correct
                            // formulas should be
                            content
                                .offset(y: 13 * abs(phase.value))
                                .rotationEffect(.degrees(10 * phase.value), anchor: .top)
                        }
                }
            }
        }
        Spacer()
    }
}
.frame(height: 160)
.clipShape(TopProtractorCurveShape())

If you want a tab-like effect like in the first screenshot, where the centered text is the currently selected tab, you can use a .viewAligned scroll target behaviour:

ZStack {
    Color.green
    // this geometry reader is needed to calculate the correct padding for the HStack,
    // such that the scroll view "snaps" at the centre of the screen
    GeometryReader { geo in
        VStack {
            Spacer()
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 0) {
                    ForEach(stringArray, id: \.self) { item in
                        Text(item)
                            .foregroundColor(.white)
                            .containerRelativeFrame(.horizontal, count: 3, spacing: 10.0)
                            .padding(.bottom, 20)
                            .scrollTransition(.interactive.threshold(.centered)) { content, phase in
                                content
                                    .offset(y: 13 * abs(phase.value))
                                    .rotationEffect(.degrees(10 * phase.value), anchor: .top)
                            }
                    }
                }
                .scrollTargetLayout()
                .padding(.horizontal, geo.size.width / 3)
            }
            .scrollTargetBehavior(.viewAligned)
            Spacer()
        }
    }
}
.frame(height: 160)
.clipShape(TopProtractorCurveShape())

Output:

enter image description here

Upvotes: 2

Related Questions