Search code examples
swiftuiscrollviewblend-mode

Use SwiftUI to turn the text overlapping the rectangle in ScrollView white


I created a view similar to a wheel picker, and when I swiped the scrollView, I wanted the color of the text to change to white when it coincided with the rectangle.I can use .blendMode to achieve the effects in the images, but when the background changes, the effect also changes. I want to know if there is any method that can be unaffected by the background color.I searched for a long time but couldn't find a suitable solution. If you could provide any help, I would greatly appreciate it.

import SwiftUI
import SwiftUIIntrospect
import Combine
@_spi(Advanced) import SwiftUIIntrospect

struct TimePickerView: View {
    @StateObject var viewModel = TimerPickerViewModel()
    private let date = ["", "1", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "70", "80", "90", "100", "110", "120", ""]
    var body: some View {
        ZStack {
            Image("bg")
                .resizable()
                .aspectRatio(contentMode: .fill)
                .ignoresSafeArea(.all)
            
            ScrollView(showsIndicators: false) {
                LazyVStack(spacing: 0) {
                    ForEach(0..<date.count, id: \.self) { index in
                        let time = date[index]
                        Text(time + (time.isEmpty ? "":" MIN"))
                            .frame(width: 200, height: 50)
                    }
                }
                .font(.system(size: 25))
                .foregroundStyle(Color(hex: 0x638FFF))
                .background(GeometryReader { geometry in
                    Color.clear
                        .preference(key: TimerOffsetPreferenceKey.self,
                                    value: geometry.frame(in: .named("TimePickeView")).origin.y)
                })
                .onPreferenceChange(TimerOffsetPreferenceKey.self) { value in
                    viewModel.contentOffset = -value
                }
            }
            .introspect(.scrollView, on: .iOS(.v15, .v16, .v17), customize: { scrollView in
                viewModel.scrollView = scrollView
            })
            .frame(height: 150)
            .coordinateSpace(name: "TimePickeView")
            
            RoundedRectangle(cornerRadius: 25)
                .fill(Color(hex: 0x638FFF))
                .frame(width: 180, height: 50)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

class TimerPickerViewModel: ObservableObject {
    @Published var contentOffset: CGFloat = 0
    private var cancellable = Set<AnyCancellable>()
    @Weak var scrollView: UIScrollView?
    init() {
        $contentOffset
            .dropFirst(2)
            .debounce(for: .seconds(0.3), scheduler: RunLoop.main)
            .sink { [weak self] output in
                let remainder = Int(output) % 50
                var offset = Int(output) - remainder + (remainder > 25 ? 50:0)
                offset = max(0, min(offset, 1000))
                self?.scrollView?.setContentOffset(CGPoint(x: 0, y: CGFloat(offset)), animated: true)
            }
            .store(in: &cancellable)
    }
}

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

#Preview {
    TimePickerView()
}

The following is a similar effect that I want:


Solution

  • You might be able to get it to work using .blendMode with the following small changes:

    • apply .compositingGroup() to the ZStack
    • set the background using .background
    ZStack {
        // content as before, but without the background image
    }
    .compositingGroup()
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background( /* the image */ )
    

    The modifier .compositingGroup isolates the effect of the blend mode to the contents of the ZStack. However, whether or not it works may depend on the colors you are using and/or the color scheme in operation (light/dark mode).

    As a more general-purpose solution, I would suggest showing a Capsule shape which has the list of dates shown as an overlay, clipped to the shape. This way, you can use any styling you like.

    • To keep the position of the overlay synchronized with the scrolled list behind it, the scroll offset can be applied as a y-offset.
    • The scroll offset can be detected using a GeometryReader in the background. You were doing this already, but you were then using a PreferenceKey to pass on the value. I would suggest, it is simpler to use an .onChange handler to update a state variable.
    • The scroll position can be made sticky by using .scrollTargetLayout.
    • The index of the scrolled item can be detected by using .scrollPosition. The selected index can then be derived from the scrolled index.

    Here is an example of how it can be implemented this way. It does not need a PreferenceKey, nor does it use introspection.

    struct TimePicker: View {
        private let rowHeight: CGFloat = 50
        private let date = ["", "1", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60", "70", "80", "90", "100", "110", "120", ""]
        @State private var scrollOffset = CGFloat.zero
        @State private var scrolledIndex: Int?
    
        var selectedIndex: Int {
            min((scrolledIndex ?? 0) + 1, date.count - 2)
        }
    
        private var dateList: some View {
            VStack(spacing: 0) {
                ForEach(Array(date.enumerated()), id: \.offset) { index, time in
                    Text(time + (time.isEmpty ? "":" MIN"))
                        .frame(height: rowHeight)
                }
            }
            .font(.system(size: 25))
        }
    
        private var scrollDetector: some View {
            GeometryReader { proxy in
                let minY = proxy.frame(in: .scrollView).minY
                Color.clear
                    .onChange(of: minY) { oldVal, newVal in
                        scrollOffset = newVal
                    }
            }
        }
    
        var body: some View {
            ZStack {
                ScrollView(showsIndicators: false) {
                    dateList
                        .foregroundStyle(.blue) // 0x638FFF
                        .background(scrollDetector)
                }
                .scrollPosition(id: $scrolledIndex)
                .scrollTargetLayout()
                .scrollTargetBehavior(.viewAligned)
    
                Capsule()
                    .fill(.blue) // 0x638FFF
                    .frame(width: 180, height: rowHeight)
                    .overlay(alignment: .top) {
                        dateList
                            .foregroundStyle(.background)
                            .offset(y: scrollOffset - rowHeight)
                    }
                    .clipped()
                    .allowsHitTesting(false)
            }
            .frame(width: 200, height: 3 * rowHeight)
            .padding(.bottom, 50)
            .overlay(alignment: .bottom) {
                Text("Selected index = \(selectedIndex)")
            }
        }
    }
    

    Animation