1
votes

I'm having this issue where when I put a rectangle over a ScrollView with a gradient fill the scrollview stops reacting to touch.

The aim is to fade out items at the bottom of the scrollview so they don't clash with a custom bottom navigation view in the parent view.

I've tried to use a .frame modifier to make the fades height only the bottom quarter or so to hopefully stop blocking the scrollview but it didnt work.

Does anyone know a way around this?

import Foundation
import SwiftUI
import CoreData


struct TestView: View {

    var managedObjectContext:NSManagedObjectContext
    var spendings:FetchedResults<Spending>
    var expenses:FetchedResults<Expense>
    var settings:FetchedResults<Settings>
    @State private var showAddSpending:Bool = false

       @Binding var selection:Int

    var body: some View{
        VStack{
            HStack{
                Text("Spending").padding()

            }.padding(.horizontal)

            ZStack{

                //List items
                ScrollView{
                    ForEach(self.spendings) { spend in
                        //if(spend.currentMonth == true){
                        HStack{
                            // IS EXPANDED
                            if spend.isExpanded {

                                VStack{

                                    HStack{
                                        //NAME
                                        if(spend.currentMonth){
                                            Text("\(spend.name)")
                                                .lineLimit(1)
                                                .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                                                .foregroundColor(Color .orange)
                                                .onLongPressGesture {
                                                    spend.currentMonth.toggle()
                                            }
                                        } else {

                                            Text("\(spend.name)")
                                                .lineLimit(1)
                                                .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                                                .onLongPressGesture {
                                                    spend.currentMonth.toggle()
                                            }
                                        }

                                        //AMOUNT
                                        Text("\(spend.amount)")


                                            .frame(minWidth: 0, maxWidth: 70, alignment: .trailing)

                                        //DELETE
                                        DeleteStyle(text: "multiply", symbol: true)
                                            .onTapGesture {
                                                self.managedObjectContext.delete(spend)
                                                do {
                                                    try self.managedObjectContext.save()
                                                }catch{
                                                    print(error)
                                                }
                                        }
                                    }
                                    VStack{


                                        //CATEGORY
                                        Text("Category: \(spend.category)")


                                            .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)

                                    }
                                }

                                .onTapGesture {
                                    spend.isExpanded.toggle()
                                }





                            } else {

                                // ISNT EXPANDED
                                HStack{
                                    //NAME
                                    if(spend.currentMonth){
                                        Text("\(spend.name)")
                                            .lineLimit(1)
                                            .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                                            .foregroundColor(Color .orange)
                                            .onLongPressGesture {
                                                spend.currentMonth.toggle()
                                        }
                                    } else {

                                        Text("\(spend.name)")
                                            .lineLimit(1)
                                            .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
                                            .onLongPressGesture {
                                                spend.currentMonth.toggle()
                                        }

                                    }

                                    Spacer()

                                    //AMOUNT
                                    Text("\(spend.amount)")


                                        .frame(minWidth: 0, maxWidth: 70, alignment: .trailing)


                                    //DELETE
                                    DeleteStyle(text: "multiply", symbol: true).onTapGesture {
                                        self.managedObjectContext.delete(spend)
                                        do {
                                            try self.managedObjectContext.save()
                                        }catch{
                                            print(error)
                                        }
                                    }
                                }

                                .onTapGesture {
                                    spend.isExpanded.toggle()
                                }


                            }
                        }
                    }
                    .foregroundColor(Color (UIColor .secondaryLabel))
                }.padding(.horizontal)

                //Button to add new item
                VStack{
                    Spacer()
                    HStack{
                        Spacer()
                        /*
                         Text("Total: £\(calculateTotalSpendingForCurrentMonth())")
                         .foregroundColor(.white)
                         .padding(15)
                         .background(Color .orange)
                         .cornerRadius(40)
                         */
                        if spendings.isEmpty {
                            HStack{
                                Text("Record a spend")
                                Image(systemName: "arrow.right")
                            }
                            .foregroundColor(Color (UIColor .secondaryLabel))
                            .padding(.bottom, 90)
                            .padding(.horizontal, 40)
                        }
                        VStack{
                            Button(action: {
                                self.showAddSpending = true
                            }) {
                                NavStyle(text: "plus", symbol: true)

                            }.sheet(isPresented: $showAddSpending) {
                                AddSpendingView(managedObjectContext: self.managedObjectContext, spendings: self.spendings, expenses: self.expenses)
                            }

                        }

                    }

                }

                //Black Fade at bottom
                VStack{
                    Spacer()
                    Rectangle()
                        .fill (
                            LinearGradient(gradient: Gradient(colors: [.clear, .black]),
                                           startPoint: .center, endPoint: .bottom)
                    )
                }
            }
        }.background(Color (UIColor.secondarySystemBackground))
    }
}
5

5 Answers

2
votes

ScrollView is transparent itself, so you can use gradient as background, like in the example below

ScrollView {
    ForEach(0..<100) { i in
        Text("Item \(i)")
    }
}
.background(LinearGradient(gradient: Gradient(colors: [.clear, .black]),
    startPoint: .center, endPoint: .bottom))

For overlays sometimes you can use the following modifier with value false to pass touches through

public func allowsHitTesting(_ enabled: Bool) -> some View

but for ScrollView, even though it passes tap gesture events, it does not appropriate completely, because scroll would be blocked.

Tested with Xcode 11.2.1 / iOS 13.2

1
votes

Really, SwiftUI View is still inappropriate to solve this problem. But I found a solution, not so pretty as I wanted.

Firstly I tried 1) to wrap UIView-gradient-overlay with SwiftUI-View via UIViewRepresentable and 2) use this wrapper in SwiftUI.body(). But result was the same as plain using SwiftUI-View.

So, a solution came using reverse way - 1) convert SwiftUI-content into UIViewController using UIHostingController and 2) use convertion of this holder-controller via UIViewControllerRepresentable in SwiftUI.body()

struct ContentView: View {

    var body: some View {

        UIViewControllerWrapper()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

====

struct UIViewControllerWrapper: UIViewControllerRepresentable {

    func makeUIViewController(context: Context) -> MyViewController {
        return MyViewController()
    }

    func updateUIViewController(_ uiViewController: MyViewController, context: Context) {}
}

====

class MyViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // SwiftUI content wrapper
        let swiftUIContent = UIHostingController(rootView: SwiftUIContent())

        swiftUIContent.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(swiftUIContent.view)

        swiftUIContent.view.leadingAnchor.constraint(
        equalTo: view.leadingAnchor).isActive = true
        swiftUIContent.view.trailingAnchor.constraint(
        equalTo: view.trailingAnchor).isActive = true
        swiftUIContent.view.topAnchor.constraint(
        equalTo: view.topAnchor).isActive = true
        swiftUIContent.view.bottomAnchor.constraint(
        equalTo: view.bottomAnchor).isActive = true
        swiftUIContent.didMove(toParent: self)


        // UIKit view overlay
        let viewOverlay = UIView(frame: UIScreen.main.bounds)

        let gradient = CAGradientLayer()
        gradient.frame = view.bounds
        gradient.colors = [UIColor.red.withAlphaComponent(0.5).cgColor, UIColor.green.withAlphaComponent(0.5).cgColor]
        viewOverlay.layer.insertSublayer(gradient, at: 0)

        viewOverlay.isUserInteractionEnabled = false
        view.addSubview(viewOverlay)

    }

}

====

struct SwiftUIContent: View{

    var body: some View {

        let body =

            ZStack{
//                ScrollView {
                List{
                    ForEach(0..<100) { i in
                        Text("Item \(i)")
                            .gesture(TapGesture()
                                    .onEnded({ _ in
                                        print("Item \(i)")
                            }))
                    }
                }
            }

        return body
    }
}

=====

So scrolling and taping work well!! Enjoy!

1
votes

Ideally this would be done using overlay to show a gradient on top of the scroll view with allowsHitTesting(false) so that the user can still scroll, but this currently doesn't work (which is possibly a SwiftUI bug).

But I was able to make this work using a ZStack and frame as you suggested. This is a drop-in replacement for ScrollView which includes an extra parameter fadeDistance which determines the height (for vertical scrolling) and/or the width (for horizontal scrolling) of the fade out effect:

struct FadingScrollView<Content: View>: View {
    let fadeDistance: CGFloat
    let axes: Axis.Set
    let showsIndicators: Bool
    let content: Content

    init(
        fadeDistance: CGFloat,
        _ axes: Axis.Set = .vertical,
        showsIndicators: Bool = true,
        @ViewBuilder content: () -> Content
    ) {
        self.fadeDistance = fadeDistance
        self.axes = axes
        self.showsIndicators = showsIndicators
        self.content = content()
    }

    var body: some View {
        ZStack(alignment: .bottomTrailing) {
            ScrollView(axes, showsIndicators: showsIndicators) {
                // Pad the content depending on the axes so that bottom or trailing
                // part of the content isn't faded when scrolling all the way to the end.
                if axes == .vertical {
                    HStack(spacing: 0) {
                        content
                        Spacer()
                    }
                    Spacer(minLength: fadeDistance)
                } else if axes == .horizontal {
                    HStack(spacing: 0) {
                        VStack(spacing: 0) {
                            content
                            Spacer()
                        }
                        Spacer(minLength: fadeDistance)
                    }
                } else {
                    HStack(spacing: 0) {
                        content
                        Spacer(minLength: fadeDistance)
                    }
                    Spacer(minLength: fadeDistance)
                }
            }

            if axes.contains(.vertical) {
                fadeGradient(for: .vertical)
                    .frame(height: fadeDistance)
                    .allowsHitTesting(false) // Maybe Apple will make this work in the future
            }


            if axes.contains(.horizontal) {
                fadeGradient(for: .horizontal)
                    .frame(width: fadeDistance)
                    .allowsHitTesting(false) // Maybe Apple will make this work in the future
            }
        }
    }

    private func fadeGradient(for axis: Axis) -> some View {
        LinearGradient(
            gradient: Gradient(colors: [
                Color(.systemBackground).opacity(0),
                Color(.systemBackground).opacity(1)
            ]),
            startPoint: axis == .vertical ? .top : .leading,
            endPoint: axis == .vertical ? .bottom : .trailing
        )
    }
}
1
votes

Using UIViewRepresentable in conjunction with .disabled(true) allows interactions with ScrollView with overlay.

struct OverlayWrapper<Content: View>: UIViewRepresentable {
    
    var content: Content
    
    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content()
    }
    
    func makeUIView(context: Context) -> UIView {
        
        let uiView = UIView()
        
        // Get reference to the SwiftUI view wrapped inside PagerScrollView
        let child = UIHostingController(rootView: content)
        
        let screenWidth = UIScreen.main.bounds.width
        let screenHeight = UIScreen.main.bounds.height
        
        // Get size of the child
        let newSize = child.view.sizeThatFits(CGSize(width: screenWidth, height: screenHeight))
        
        let frame = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height)
        
        child.view.frame = frame
        child.view.backgroundColor = UIColor.clear

        uiView.frame = frame
        uiView.backgroundColor = UIColor.clear
        
        uiView.addSubview(child.view)
        
        return uiView
    }
    
    
    func updateUIView(_ uiView: UIView, context: Context) {
        // If the UI is not updated, updateUIView will not be called
    }
}

An example of using the OverlayWrapper:

struct ExampleView: View {
    
    var body: some View {
        ZStack {
            ScrollView(.vertical) {
                VStack {
                    ForEach(0..<100) { i in
                        Text("\(i)")
                    }
                }
            }
            OverlayWrapper() {
                // This can be replaced with any SwiftUI view.
                Rectangle()
                    .frame(width: 200, height: 200)
                    .foregroundColor(Color.blue.opacity(0.5))
            }
            .frame(width: 200, height: 200, alignment: .center)
            .disabled(true)
        }
    }
}
0
votes

If someone is looking how pass swipe gestures to scrollView, .allowsHitTesting(false) will pass ONLY tap gesture.

ZStack {
    ScrollView {
        Text("Lorem ipsum dolor sit amet, consectetuer...")
    }
    LinearGradient(gradient: Gradient(colors: [Color.black,
                                                       Color.clear]),
                           startPoint: .top,
                           endPoint: .bottom)
      .allowsHitTesting(false)
}

The solution is using .mask modifier for passing all gestures to scrollView

var body: some View {
        ScrollView {
                Text("Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc")
                    .multilineTextAlignment(.center)
            }
        }
        .mask(
            LinearGradient(gradient: Gradient(colors: [Color.black,
                                                       Color.black,
                                                       Color.clear]),
                           startPoint: .top,
                           endPoint: .bottom)
        )
        .ignoresSafeArea(edges: .bottom)
    }
}

enter image description here