如何在弹出 Overlay Highlight 的 VStack 中创建 SwiftUI 视图

How to Create a SwiftUI View in a VStack That Pops Out with Overlay Highlight

提问人:fasoh 提问时间:10/17/2023 更新时间:10/17/2023 访问量:65

问:

请考虑以下简化代码片段

import SwiftUI

struct ContentView: View {
  var body: some View {
    ScrollView {
      // Some other views here that should also be covered
      VStack {
        ForEach(0..<8) { i in
          ListElement()
            .padding(.horizontal)
        }
      }
    }
  }
  
}

struct ListElement: View {
  @State private var isFocused: Bool = false
  
  var body: some View {
    Button(action: {
      self.isFocused.toggle()
    }, label: {
      Text(isFocused ? "I am focused" : "I am not focused")
        .foregroundColor(.white)
        .frame(maxWidth: .infinity)
        .padding()
        .background(.gray)
        .background(
          isFocused ?
          Color.pink
            .opacity(0.5)
            .frame(width: 10000, height: 10000)
            .ignoresSafeArea()
            .onTapGesture {
              self.isFocused = false
            }
          : nil
        )
    })
  }
}

#Preview {
  ContentView()
}

其结果如下

snippet preview

通常,ScrollView 包含更多的 ListElements。 如果用户点击一个 ListElement,我希望它调整大小并显示其他信息,这是一个简单的按钮。 但是,我也希望除 ListElement 之外的所有内容都具有透明的覆盖层,以便更加强调选定的 ListElement 并淡化其他所有内容。 如果用户点击除所选 ListElement(在本例中为其背景)以外的任何内容,则不应再聚焦相应的 ListElement。 有一些解决方案(例如类似“教程”的视图),但据我所知,由于依赖于 GeometryReader,因此在 ScrollViews 中不起作用。

在示例代码中,我尝试在每个 ListElement 上使用一个大得离谱的 -modifier 来实现这一点,该修饰符将覆盖整个屏幕。 它看起来像这样:background

code snippet on tap listelement

万岁!VStack 在视觉上被淡化,选定的 ListElement 清晰地处于焦点中。但是,如果选择了除最后一个 ListElement 之外的任何一个,则会发生以下情况:

code snipped on tap listelement any but the last

如您所见,-modifier 不覆盖分层选中的 ListElement 之后的任何 ListElement。我认为这是由于 SwiftUI 中的视图层次结构。有没有办法将当前选择的 ListElement 推送到层次结构的顶部,以便我可以覆盖它后面的任何其他内容?background

iOS SwiftUI UI滚动视图

评论


答:

1赞 Zoli 10/17/2023 #1

要回答您的问题,您必须向 ListElement 的按钮添加修饰符。但是您还必须将焦点状态存储在全局位置,因为 - 我猜 - 如果一个列表元素是焦点的,我们应该从先前焦点元素(如果有的话)中删除焦点。.zIndex(isFocused ? 1 : -1)

因此,代码如下所示:

import SwiftUI
import Foundation

struct ContentView: View {

  @State var focusedIndex: Int?

  var body: some View {
    ScrollView {
      Text("Some other views here that should also be covered")
      VStack {
        ForEach(0..<8) { i in
          ListElement(
            isFocused: focusedIndex == i,
            action: { focusedIndex = i }
          )
          .padding(.horizontal)
        }
      }
    }
  }

}

struct ListElement: View {
  let isFocused: Bool
  let action: () -> Void

  var body: some View {
    Button(action: {
      action()
    }, label: {
      Text(isFocused ? "I am focused" : "I am not focused")
        .foregroundColor(.white)
        .frame(maxWidth: .infinity)
        .padding()
        .background(.gray)
        .background(
          isFocused ?
          Color.pink
            .opacity(0.5)
            .frame(width: 10000, height: 10000)
            .ignoresSafeArea()
          : nil
        )

    })
    .zIndex(isFocused ? 1 : -1)
  }
}

#Preview {
  ContentView()
}

虽然它有效,但我对这个解决方案并不完全满意。我觉得它很笨拙,我没有 100% 的信心它会在每种情况下都起作用。

因此,我认为更好的解决方案是在完整内容上方画一个“封面”,并在特定位置切一个洞,使屏幕的一部分可见。为此,我们必须确定给定元素在屏幕中的确切位置,然后我们必须绘制封面并使用“反向掩码”在该位置切一个洞。 该解决方案具有一些优点,例如它不仅适用于列表元素,还适用于屏幕上的任何类型的视图。我们也可以将其扩展到“切割”不是矩形,而是任何形状的孔。

这个解决方案还有一个未解决的问题:如果你有很多元素,假设你滚动你的内容,那么孔的位置将是不正确的。如果您检查滚动视图的 contentOffset 并从测量的 CGRect 的 origin.x 中减去滚动视图的 offset.x,则可以解决它。 我没有实现它,因为我的代码太复杂了,但您可以使用作为帮助轻松扩展此代码。

import SwiftUI

struct ContentView: View {

  @State var focusedRect: CGRect?
  @State var focusedIndex: Int?

  var body: some View {
    TutorialContent(
      highlightRect: focusedRect,
      action: {
        focusedRect = nil
        focusedIndex = nil
      },
      content: {
        ScrollView {
          VStack {
            VStack {
              Text("Another view which will be covered!")
            }
            ForEach(0 ..< 8) { i in
              let isFocused = i == focusedIndex
              ListElement(
                text: isFocused ? "I am focused" : "I am not focused",
                action: { rect in
                  focusedRect = rect
                  focusedIndex = i
                }
              )
              .padding(.horizontal)
            }
          }
        }
      }
    )
    .animation(.default, value: focusedIndex)
  }
}

struct ListElement: View {
  let text: String
  let action: (CGRect) -> Void

  @State var rect: CGRect = .zero

  var body: some View {
    Button(
      action: {
        action(rect)
      },
      label: {
        Text(text)
          .padding()
          .foregroundColor(.white)
          .frame(maxWidth: .infinity)
          .background(.gray)
      })
    .measure { rect = $0 }
  }
}

struct TutorialContent<Content: View>: View {
  let highlightRect: CGRect?
  let action: () -> Void
  let content: () -> Content

  var body: some View {
    ZStack {
      if let rect = highlightRect {
        ZStack {
          Color.pink.opacity(0.5)
          Rectangle()
            .frame(
              width: rect.width,
              height: rect.height
            )
            .position(CGPoint(x: rect.midX, y: rect.midY))
            .blendMode(.destinationOut)
        }
        .ignoresSafeArea(.all)
        .compositingGroup()
        .zIndex(highlightRect != nil ? 1 : -1)
        .onTapGesture {
          action()
        }
      }

      content()
    }
  }
}

extension View {
  @ViewBuilder func measure(
    in coordinateSpace: CoordinateSpace = .global,
    _ block: @escaping (CGRect) -> Void
  ) -> some View {
    MeasuredView(coordinateSpace: coordinateSpace, onMeasure: block, content: self)
  }
}

struct MeasuredView<Content: View>: View {
  let coordinateSpace: CoordinateSpace
  let onMeasure: (CGRect) -> Void
  let content: Content

  var body: some View {
    content
      .overlay {
        GeometryReader { proxy in
          Rectangle().fill(.clear)
            .task {
              onMeasure(proxy.frame(in: coordinateSpace))
            }
        }
      }
  }
}

#Preview {
  ContentView()
}