Search code examples
iosanimationswiftui

Detect subview's changing position dynamically in SwiftUI


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)
        }
    }
}

Solution

  • 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)
            }
        }
    }
    

    Animation


    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
            }
    }
    

    Animation