I have the following code wherein I'm updating the y-position of a rectangle with a timer. Given the following view hierarchy and without using absolute positions, how can I detect as soon as it goes past the lineView
which is a subview?
struct ColorView: View {
private let blockWidth = 40.0
private let blockHeight = 40.0
@State private var blockYPos = 0.0
private let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
var body: some View {
GeometryReader { geometry in
ZStack {
// position updated via timer
Rectangle()
.frame(width: blockWidth, height: blockHeight)
.position(x: geometry.size.width/2, y: blockYPos)
VStack {
HStack {
scribble
Spacer()
close
}
.font(.title)
.frame(height: 40)
palleteView
lineView
Spacer()
}
}
.background {
Color.yellow.ignoresSafeArea()
}
.onAppear {
blockYPos = geometry.size.height - blockHeight
}
.onReceive(timer) { _ in
blockYPos -= 5
}
}
}
private var lineView: some View {
Capsule()
.frame(height: 2)
.foregroundStyle(Color.red)
.padding(.horizontal)
}
private var close: some View {
Image(systemName: "xmark.circle.fill")
.padding(.trailing, 8)
}
private var scribble: some View {
Image(systemName: "scribble.variable")
.padding(.leading, 8)
}
private var palleteView: some View {
HStack(alignment: .center) {
Rectangle()
.fill(.green)
.frame(width: blockWidth, height: blockHeight)
Rectangle()
.fill(.blue)
.frame(width: blockWidth, height: blockHeight)
Rectangle()
.fill(.orange)
.frame(width: blockWidth, height: blockHeight)
Rectangle()
.fill(Color.indigo)
.frame(width: blockWidth, height: blockHeight)
Rectangle()
.fill(Color.mint)
.frame(width: blockWidth, height: blockHeight)
}
}
}
In the question you said you wanted to detect when the block has reached the line without using absolute positions.
Although it is easy for a human observer to see when the block has reached the line, it is very difficult to determine it programmatically without any knowledge of the position of the line. One very convoluted way might be as follows:
Take a screenshot of the area defined by the frame of the block. An example of how to do this was provided by kontiki in this answer.
Inspect the colors in the screenshot. An example of how to do this was provided by Grimxn in this answer.
If the screenshot is not all black then it means the block is behind some other content.
In the case of your example, the first view that the block goes behind will be the line. So this approach will tell you when the block has reached the line.
The color inspection can be made less computationally expensive by examining the pixels along the center line only.
Here is the updated example:
struct ColorView: View {
@State private var isBlockBehindOtherContent = false
// + other variables and properties, as before
var body: some View {
GeometryReader { geometry in
ZStack {
// position updated via timer
Rectangle()
.frame(width: blockWidth, height: blockHeight)
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .global)
} action: { rect in
if let scene = UIApplication.shared.connectedScenes.first(
where: { $0.activationState == .foregroundActive }
) as? UIWindowScene {
// Capture a snapshot of the block
if let snapshot = scene.windows[0].rootViewController?.view.asImage(rect: rect) {
// Inspect the colors in the snapshot
isBlockBehindOtherContent = !isImageAllBlack(image: Image(uiImage: snapshot))
}
}
}
.position(x: geometry.size.width/2, y: blockYPos)
VStack {
// ...content as before
}
}
.background(isBlockBehindOtherContent ? .pink : .yellow)
// + .onAppear and .onReceive, as before
}
}
// Credit to Grimxn for the color inspection solution
// https://stackoverflow.com/a/77507445/20386264
private func isImageAllBlack(image: Image) -> Bool {
var result = true
if let cgi = ImageRenderer(content: image).cgImage,
let dp = cgi.dataProvider,
let pixelData = dp.data {
let x = cgi.width / 2
for y in 0..<cgi.height {
let pixelInfo: Int = (cgi.bytesPerRow * Int(y)) + Int(x) * 4
if cgi.bitmapInfo.contains(.byteOrderDefault) {
let data: UnsafePointer<UInt8> = CFDataGetBytePtr(pixelData)
let r = CGFloat(data[pixelInfo + 0]) / CGFloat(255)
let g = CGFloat(data[pixelInfo + 1]) / CGFloat(255)
let b = CGFloat(data[pixelInfo + 2]) / CGFloat(255)
if r > 0 || g > 0 || b > 0 {
result = false
break
}
}
}
}
return result
}
// + other computed properties, as before
}
// Credit to kontiki for the screenshot solution
// https://stackoverflow.com/a/57206207/20386264
private extension UIView {
func asImage(rect: CGRect) -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: rect)
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
}
}
For the record, a much easier way to detect when the block has reached the line is to measure the absolute positions of the line and the block in the global coordinate space when the view is first shown.
If .onGeometryChange
is applied after the .position
modifier then it will always be measuring the initial position. This means, the position of the block is only read on initial show. Compare this to the solution above, where .onGeometryChange
was applied before the .position
modifier and was being triggered on each change of position.
A computed property can then be used to signal when the block is behind the line, computed using the current position offset.
@State private var yLine = CGFloat.zero
@State private var yBlock = CGFloat.zero
private var isBlockBehindLine: Bool {
yLine >= yBlock + blockYPos - (blockHeight / 2) &&
yLine <= yBlock + blockYPos + (blockHeight / 2)
}
// position updated via timer
Rectangle()
.fill(isBlockBehindLine ? .pink : .primary) // 👈 added
.frame(width: blockWidth, height: blockHeight)
.position(x: geometry.size.width/2, y: blockYPos)
.onGeometryChange(for: CGFloat.self) { proxy in // 👈 added
proxy.frame(in: .global).minY
} action: { minY in
yBlock = minY
}
private var lineView: some View {
Capsule()
.frame(height: 2)
.foregroundStyle(Color.red)
.padding(.horizontal)
.onGeometryChange(for: CGFloat.self) { proxy in // 👈 added
proxy.frame(in: .global).minY
} action: { minY in
yLine = minY
}
}