SwiftUI Tutorial: Creating a Cyberpunk-Inspired Scrollable Text Reader in SwiftUI

Hello, fellow coders! Today, we're going to dive into the neon-lit world of SwiftUI to create a cyberpunk-inspired scrollable text reader. This tutorial will walk you through the process of building a custom scroll view with a sleek progress indicator and a unique "back to top" feature. Let's get started!

The Concept

Our app will feature a dark-themed scrollable view with green accents, reminiscent of classic terminal interfaces. As users scroll through the content, they'll see a progress bar that transforms into a circular progress wheel when idle. When they reach the bottom, an "arrow up" button appears to quickly return to the top.

Key Components

  1. ContentView: The main view that handles scrolling and progress tracking.
  2. ProgressWheel: A circular progress indicator.
  3. TextView: The view that contains our cyberpunk-themed text content.

Step 1: Setting Up the ContentView

Let's start by creating our ContentView struct:

struct ContentView<Content: View>: View {
    @State private var scrollOffset: CGFloat = 0
    @State private var contentHeight: CGFloat = 0
    @State private var showArrow: Bool = false
    @State private var isAtBottom: Bool = false
    @State private var isScrolling: Bool = false
    @State private var showWheel: Bool = false
    @State private var isTouching: Bool = false
    var content: Content

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    // ... body implementation ...
}

This view uses several @State properties to track scrolling behavior and view states.

Step 2: Implementing the ScrollView

Inside the body of ContentView, we'll set up our custom ScrollView:

var body: some View {
    ScrollViewReader { scrollProxy in
        GeometryReader { fullView in
            ZStack(alignment: .bottom) {
                ScrollView {
                    // ... ScrollView content ...
                }
                .scrollIndicators(.hidden)
                .gesture(
                    DragGesture(minimumDistance: 0)
                        .onChanged { _ in
                            self.isTouching = true
                            self.showWheel = false
                        }
                        .onEnded { _ in
                            self.isTouching = false
                            startWheelTimer()
                        }
                )
                // ... preference changes and other modifiers ...

                progressView(fullView: fullView, scrollProxy: scrollProxy)
            }
        }
    }
    .preferredColorScheme(.dark)
}

This setup allows us to track scroll position and content height using preference keys.

Step 3: Creating the Progress Indicator

We'll implement a progressView function to display either a progress bar or a progress wheel:

func progressView(fullView: GeometryProxy, scrollProxy: ScrollViewProxy) -> some View {
    let progress = min(max(0, -scrollOffset / (contentHeight - fullView.size.height)), 1)
    let progressPercentage = Int(progress * 100)

    return ZStack {
        // ... Progress bar implementation ...

        if showWheel {
            ProgressWheel(progress: progress)
                // ... styling and positioning ...
        }
    }
    // ... animations ...
}

This function calculates the scroll progress and displays either a bar or wheel based on the current state.

Step 4: Implementing the ProgressWheel

Create a separate ProgressWheel struct for the circular progress indicator:

struct ProgressWheel: View {
    var progress: CGFloat

    var body: some View {
        ZStack {
            Circle()
                .stroke(lineWidth: 4)
                .opacity(0.3)
                .foregroundColor(.green)

            Circle()
                .trim(from: 0.0, to: progress)
                .stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round))
                .foregroundColor(.green)
                .rotationEffect(Angle(degrees: 270.0))
                .animation(.linear, value: progress)

            Text("\(Int(progress * 100))%")
                .font(.system(size: 14, weight: .bold, design: .monospaced))
                .foregroundColor(.green)
        }
    }
}

This wheel will display the current progress percentage in a circular format.

Step 5: Creating the TextView

Finally, let's implement the TextView that will showcase our cyberpunk-themed content:

struct TextView: View {
    var body: some View {
        ContentView {
            VStack {
                Text("CodeWithWilliamJiamin")
                    .font(.title)
                    .fontWeight(.bold)
                    .foregroundColor(.green)
                    .padding(.bottom, 10)

                ZStack {
                    RoundedRectangle(cornerRadius: 10)
                        .fill(Color(red: 0.1, green: 0.1, blue: 0.1))
                        .stroke(Color.green, lineWidth: 2)
                        .shadow(color: Color.green.opacity(0.5), radius: 10)

                    ScrollView {
                        Text(introText)
                            .font(.custom("Courier", size: 16))
                            .foregroundColor(.green)
                            .padding()
                    }
                }
                .padding()
            }
            .background(Color(red: 0.05, green: 0.05, blue: 0.05))
        }
    }

    // ... introText implementation ...
}

This view wraps our content in the ContentView and applies cyberpunk-inspired styling.

Full Code

import SwiftUI

struct ContentView<Content: View>: View {
    @State private var scrollOffset: CGFloat = 0
    @State private var contentHeight: CGFloat = 0
    @State private var showArrow: Bool = false
    @State private var isAtBottom: Bool = false
    @State private var isScrolling: Bool = false
    @State private var showWheel: Bool = false
    @State private var isTouching: Bool = false
    var content: Content

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    var body: some View {
        ScrollViewReader { scrollProxy in
            GeometryReader { fullView in
                ZStack(alignment: .bottom) {
                    ScrollView {
                        GeometryReader { scrollViewGeo in
                            Color.clear.preference(key: ScrollOffsetKey.self, value: scrollViewGeo.frame(in: .global).minY)
                        }
                        .frame(height: 0)
                        .id(0)

                        VStack {
                            content
                        }
                        .padding(.horizontal)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(GeometryReader { contentGeo in
                            Color.clear.preference(key: ContentHeightPreferenceKey.self, value: contentGeo.size.height)
                        })
                    }
                    .scrollIndicators(.hidden)
                    .gesture(
                        DragGesture(minimumDistance: 0)
                            .onChanged { _ in
                                self.isTouching = true
                                self.showWheel = false
                            }
                            .onEnded { _ in
                                self.isTouching = false
                                startWheelTimer()
                            }
                    )
                    .onPreferenceChange(ScrollOffsetKey.self) { value in
                        self.scrollOffset = value - fullView.safeAreaInsets.top
                        self.isAtBottom = -scrollOffset >= (contentHeight - fullView.size.height)
                        self.isScrolling = true
                        self.showWheel = false

                        startWheelTimer()
                    }
                    .onPreferenceChange(ContentHeightPreferenceKey.self) {
                        self.contentHeight = $0
                    }

                    progressView(fullView: fullView, scrollProxy: scrollProxy)
                }
            }
        }
        .preferredColorScheme(.dark)
    }

    private func startWheelTimer() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            if !self.isScrolling && !self.isTouching {
                withAnimation(.easeInOut(duration: 0.5)) {
                    self.showWheel = true
                }
            }
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self.isScrolling = false
        }
    }

    func progressView(fullView: GeometryProxy, scrollProxy: ScrollViewProxy) -> some View {
        let progress = min(max(0, -scrollOffset / (contentHeight - fullView.size.height)), 1)
        let progressPercentage = Int(progress * 100)

        return ZStack {
            if !showWheel {
                HStack(spacing: 10) {
                    if isAtBottom {
                        Button(action: {
                            withAnimation(.easeInOut(duration: 1.5)) {
                                scrollProxy.scrollTo(0, anchor: .top)
                            }
                        }) {
                            Image(systemName: "arrow.up")
                                .font(.system(size: 24, weight: .bold))
                                .foregroundColor(.green)
                                .frame(width: 55, height: 55)
                                .background(
                                    ZStack {
                                        RoundedRectangle(cornerRadius: 10)
                                            .fill(Color.black)
                                        RoundedRectangle(cornerRadius: 10)
                                            .stroke(Color.green, lineWidth: 2)
                                    }
                                )
                                .shadow(color: Color.green.opacity(0.8), radius: 5, x: 0, y: 0)
                        }
                        .transition(.asymmetric(insertion: .scale.combined(with: .slide), removal: .scale.combined(with: .opacity)))
                    }

                    ZStack(alignment: .leading) {
                        RoundedRectangle(cornerRadius: 10)
                            .fill(Color.black)
                            .frame(width: 190, height: 55)

                        RoundedRectangle(cornerRadius: 10)
                            .stroke(Color.green, lineWidth: 2)
                            .frame(width: 190, height: 55)
                            .shadow(color: Color.green.opacity(0.8), radius: 5, x: 0, y: 0)

                        HStack(spacing: 10) {
                            Text("\(progressPercentage)%")
                                .font(.system(size: 18, weight: .bold, design: .monospaced))
                                .foregroundColor(.green)
                                .frame(width: 60, alignment: .leading)

                            GeometryReader { geo in
                                ZStack(alignment: .leading) {
                                    RoundedRectangle(cornerRadius: 5)
                                        .fill(Color.green.opacity(0.3))
                                        .frame(width: geo.size.width, height: 8)

                                    RoundedRectangle(cornerRadius: 5)
                                        .fill(Color.green)
                                        .frame(width: geo.size.width * CGFloat(progress), height: 8)
                                        .shadow(color: Color.green.opacity(0.8), radius: 5, x: 0, y: 0)
                                }
                            }
                            .frame(height: 8)
                        }
                        .padding(.horizontal, 20)
                    }
                }
                .frame(width: isAtBottom ? 260 : 190, alignment: .trailing)
                .frame(maxWidth: .infinity, alignment: .center)
                .padding(.bottom, 20)
                .transition(.opacity)
            }

            if showWheel {
                ProgressWheel(progress: progress)
                    .frame(width: 60, height: 60)
                    .background(
                        Circle()
                            .fill(Color.black)
                            .shadow(color: Color.green.opacity(0.8), radius: 10, x: 0, y: 0)
                    )
                    .transition(.scale.combined(with: .opacity))
                    .frame(maxWidth: .infinity, alignment: .trailing)
                    .padding(.trailing, 20)
                    .padding(.bottom, 20)
            }
        }
        .animation(.spring(response: 0.5, dampingFraction: 0.7), value: progressPercentage)
        .animation(.spring(response: 0.5, dampingFraction: 0.7), value: isAtBottom)
        .animation(.easeInOut(duration: 0.5), value: showWheel)
    }
}

struct ProgressWheel: View {
    var progress: CGFloat

    var body: some View {
        ZStack {
            Circle()
                .stroke(lineWidth: 4)
                .opacity(0.3)
                .foregroundColor(.green)

            Circle()
                .trim(from: 0.0, to: progress)
                .stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round))
                .foregroundColor(.green)
                .rotationEffect(Angle(degrees: 270.0))
                .animation(.linear, value: progress)

            Text("\(Int(progress * 100))%")
                .font(.system(size: 14, weight: .bold, design: .monospaced))
                .foregroundColor(.green)
        }
    }
}

struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

struct ContentHeightPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

import SwiftUI

struct TextView: View {
    var body: some View {
        ContentView {
            VStack {
                Text("CodeWithWilliamJiamin")
                    .font(.title)
                    .fontWeight(.bold)
                    .foregroundColor(.green)
                    .padding(.bottom, 10)

                ZStack {
                    RoundedRectangle(cornerRadius: 10)
                        .fill(Color(red: 0.1, green: 0.1, blue: 0.1))
                        .stroke(Color.green, lineWidth: 2)
                        .shadow(color: Color.green.opacity(0.5), radius: 10)

                    ScrollView {
                        Text(introText)
                            .font(.custom("Courier", size: 16))
                            .foregroundColor(.green)
                            .padding()
                    }
                }
                .padding()
            }
            .background(Color(red: 0.05, green: 0.05, blue: 0.05))
        }
    }

    var introText: String {
        """
        Welcome to the digital realm of William!

        [SYSTEM INIT...]
        [LOADING PROFILE...]
        [PROFILE LOADED SUCCESSFULLY]

        Greetings, fellow coders and tech enthusiasts! I'm William, your guide through the neon-lit corridors of code and creativity. As a passionate coder and dedicated YouTuber, I'm here to illuminate the path of programming for aspiring developers across the globe.

        [CHANNEL INFO]
        Name: CodeWithWilliam
        Mission: Decrypting the complexities of coding, one video at a time
        Status: Online and thriving

        Are you ready to embark on an epic coding adventure? My YouTube channel, CodeWithWilliam, is your portal to a world where lines of code transform into powerful applications and innovative solutions. From beginner-friendly tutorials to advanced coding techniques, we explore it all!

        [CONTENT OVERVIEW]
        - Python Mastery
        - Web Development Wizardry
        - Mobile App Alchemy
        - Data Science Decryption
        - Machine Learning Marvels
        - And much more!

        But wait, there's more! By joining our Patreon community, you gain access to an exclusive digital vault filled with:

        [PATREON PERKS]
        - Complete source code for all projects
        - Early access to upcoming videos
        - Personalized coding advice
        - Exclusive behind-the-scenes content
        - Vote on future video topics
        - Join our private Discord server

        The best part? All the code you download through our Patreon can be used commercially! That's right - build, innovate, and monetize with confidence.

        [CALL TO ACTION]
        Don't let this opportunity slip through your fingers! Join our cyberpunk coding revolution now:

        1. Subscribe to CodeWithWilliam on YouTube
        2. Become a Patreon supporter for exclusive benefits
        3. Download, learn, and create without limits

        Remember, in this digital age, code is the new currency, and knowledge is power. Together, we'll hack the matrix of possibilities and sculpt the future of technology.

        Are you ready to level up your coding skills? The neon-lit path to programming mastery awaits. Join me, William, and let's code our way to a brighter, more innovative future!

        [END TRANSMISSION]

        #CodeWithWilliam #CodingRevolution #PatreonPerks #LearnToCode #FutureOfTech
        """
    }
}

#Preview {
    TextView()
}

//
//  CyberTextReaderApp.swift
//  CyberTextReader
//
//  Created by WilliamJiamin on 2024/8/4.
//

import SwiftUI

@main
struct CyberTextReaderApp: App {
    var body: some Scene {
        WindowGroup {
            TextView()
        }
    }
}

Conclusion

And there you have it! We've created a cyberpunk-inspired scrollable text reader with a custom progress indicator and smooth animations. This project demonstrates the power of SwiftUI in creating complex, interactive user interfaces with relatively little code.

Remember, the key to mastering SwiftUI is practice and experimentation. Try modifying this project to add your own unique features or styling. Happy coding, and may your terminal always glow with the green light of success!