Article / 2026/05/29

FloatView 浮窗控件

FloatView 浮窗控件 的技术笔记。


//
//  FloatView.swift
//  wscanner
//
//  Created by yunshen on 2025/6/18.
//
import Foundation
import SwiftUI

/// 吸边类型
/// - `.horizontal` 水平吸边
/// - `.vertical` 垂直吸边
/// - `.all` 靠近那边吸那边
public enum SnapToEdgeAlignment {
    /// `.horizontal` 水平吸边
    case horizontal
    /// `.vertical` 垂直吸边
    case vertical
    /// `.all` 靠近那边吸那边
    case all
}

public struct FloatView<Content: View>: View {
    @ViewBuilder var content: () -> Content
    @State var contentSize: CGSize = .zero

    // 使用 @GestureState 来跟踪拖拽状态
    @GestureState var isDragging = false

    // 使用 @State 来保存拖拽过程中需要的数据
    @State private var dragStartOffset: CGPoint = .zero

    @State var currentPoint: CGPoint = .zero
    
    // 添加一个标志来跟踪是否已经初始化
    @State private var isInitialized = false

    /// `alignment` 相对父控件中的对其方式
    let alignment: Alignment

    /// `enableSnapToEdge` 吸边控制的开关
    let enableSnapToEdge: Bool
    /// `snapAnimationDuration` 开启吸边控制后,吸边的动画时间
    let snapAnimationDuration: Double
    /// `snapEdgeAlignment` enableSnapToEdge 为true时生效,吸边的方式
    var snapEdgeAlignment: SnapToEdgeAlignment

    /// - Parameter:
    ///   - alignment 相对父控件中的对其方式 记得设置父容器的大小
    ///   - offset 初始化时,在 alignment 位置基础上的偏移量
    ///   - enableSnapToEdge 开启吸边的效果
    ///   - snapAnimationDuration 吸边时的动画时间
    ///   - snapEdgeAlignment enableSnapToEdge 为true时生效,吸边的方式
    ///   - content 控件
    public init(
        alignment: Alignment = .trailing,
        offset: CGPoint = .zero,
        enableSnapToEdge: Bool = false,
        snapAnimationDuration: Double = 0.3,
        snapEdgeAlignment: SnapToEdgeAlignment = .all,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.alignment = alignment
        self.content = content
        self.enableSnapToEdge = enableSnapToEdge
        self.snapAnimationDuration = snapAnimationDuration
        self.snapEdgeAlignment = snapEdgeAlignment
        self._currentPoint = State(initialValue: offset)
    }

    public var body: some View {
        GeometryReader { geometry in
            ZStack(alignment: .center) {
                content()
                    .background(
                        GeometryReader { contentGeo in
                            Color.clear
                                .preference(key: SizePreferenceKey.self, value: contentGeo.size)
                        }
                    )
                    .onPreferenceChange(SizePreferenceKey.self) { newSize in
                        if contentSize != newSize {
                            contentSize = newSize
                            // 当 contentSize 更新时,验证并调整当前位置
                            if !isInitialized && contentSize != .zero {
                                let bounds = getBounds(geometry: geometry)
                                currentPoint = clampToBounds(currentPoint, geometry: geometry, bounds: bounds)
                                isInitialized = true
                            }
                        }
                    }
                    .offset(x: currentPoint.x, y: currentPoint.y)
                    .simultaneousGesture(
                        getGesture(geometry: geometry),
                        isEnabled: contentSize != .zero  // 只有在获取到尺寸后才启用手势
                    )
            }
            .frame(
                maxWidth: .infinity,
                maxHeight: .infinity,
                alignment: alignment
            )
        }
    }

    // 计算边界限制(根据对齐方式调整)
    private func getBounds(geometry: GeometryProxy) -> (
        minX: CGFloat, maxX: CGFloat, minY: CGFloat, maxY: CGFloat
    ) {
        // 如果 contentSize 还没有获取到,返回零边界
        guard contentSize != .zero else {
            return (minX: 0, maxX: 0, minY: 0, maxY: 0)
        }
        
        let containerSize = geometry.size
        let safeArea = geometry.safeAreaInsets

        // 计算初始位置(SwiftUI 自动放置的位置)
        let initialPosition = getInitialPositionForAlignment(geometry: geometry)

        // 计算整个容器的可用边界
        let containerMinX =
            -containerSize.width / 2 + contentSize.width / 2 + safeArea.leading
        let containerMaxX =
            containerSize.width / 2 - contentSize.width / 2 - safeArea.trailing
        let containerMinY =
            -containerSize.height / 2 + contentSize.height / 2 + safeArea.top
        let containerMaxY =
            containerSize.height / 2 - contentSize.height / 2 - safeArea.bottom

        // 计算相对于初始位置的偏移边界
        let minX = containerMinX - initialPosition.x
        let maxX = containerMaxX - initialPosition.x
        let minY = containerMinY - initialPosition.y
        let maxY = containerMaxY - initialPosition.y

        return (minX: minX, maxX: maxX, minY: minY, maxY: maxY)
    }

    // 计算初始位置(与 SwiftUI 的 frame(alignment:) 保持一致)
    private func getInitialPositionForAlignment(geometry: GeometryProxy)
        -> CGPoint
    {
        let containerSize = geometry.size
        let safeArea = geometry.safeAreaInsets

        // 可用区域的实际边界
        let availableWidth =
            containerSize.width - safeArea.leading - safeArea.trailing
        let availableHeight =
            containerSize.height - safeArea.top - safeArea.bottom

        // 可用区域的中心点
        let availableCenterX =
            safeArea.leading + availableWidth / 2 - containerSize.width / 2
        let availableCenterY =
            safeArea.top + availableHeight / 2 - containerSize.height / 2

        switch alignment {
        case .topLeading:
            return CGPoint(
                x: availableCenterX - availableWidth / 2 + contentSize.width
                    / 2,
                y: availableCenterY - availableHeight / 2 + contentSize.height
                    / 2
            )
        case .top:
            return CGPoint(
                x: availableCenterX,
                y: availableCenterY - availableHeight / 2 + contentSize.height
                    / 2
            )
        case .topTrailing:
            return CGPoint(
                x: availableCenterX + availableWidth / 2 - contentSize.width
                    / 2,
                y: availableCenterY - availableHeight / 2 + contentSize.height
                    / 2
            )
        case .leading:
            return CGPoint(
                x: availableCenterX - availableWidth / 2 + contentSize.width
                    / 2,
                y: availableCenterY
            )
        case .center:
            return CGPoint(x: availableCenterX, y: availableCenterY)
        case .trailing:
            return CGPoint(
                x: availableCenterX + availableWidth / 2 - contentSize.width
                    / 2,
                y: availableCenterY
            )
        case .bottomLeading:
            return CGPoint(
                x: availableCenterX - availableWidth / 2 + contentSize.width
                    / 2,
                y: availableCenterY + availableHeight / 2 - contentSize.height
                    / 2
            )
        case .bottom:
            return CGPoint(
                x: availableCenterX,
                y: availableCenterY + availableHeight / 2 - contentSize.height
                    / 2
            )
        case .bottomTrailing:
            return CGPoint(
                x: availableCenterX + availableWidth / 2 - contentSize.width
                    / 2,
                y: availableCenterY + availableHeight / 2 - contentSize.height
                    / 2
            )
        default:
            return CGPoint(x: availableCenterX, y: availableCenterY)
        }
    }

    // 限制位置在边界内
    private func clampToBounds(
        _ point: CGPoint,
        geometry: GeometryProxy,
        bounds: (minX: CGFloat, maxX: CGFloat, minY: CGFloat, maxY: CGFloat)
    ) -> CGPoint {
        let clampedX = max(bounds.minX, min(bounds.maxX, point.x))
        let clampedY = max(bounds.minY, min(bounds.maxY, point.y))

        return CGPoint(x: clampedX, y: clampedY)
    }

    // 计算吸边位置
    private func calculateSnapPosition(
        _ point: CGPoint,
        bounds: (minX: CGFloat, maxX: CGFloat, minY: CGFloat, maxY: CGFloat),
        snapEdgeAlignment: SnapToEdgeAlignment
    ) -> CGPoint {
        guard enableSnapToEdge else { return point }
        var snapPoint = point

        // 计算到各边的距离
        let distanceToLeft = abs(point.x - bounds.minX)
        let distanceToRight = abs(point.x - bounds.maxX)
        let distanceToTop = abs(point.y - bounds.minY)
        let distanceToBottom = abs(point.y - bounds.maxY)

        switch snapEdgeAlignment {
        case .horizontal:
            let minDistance = min(distanceToLeft, distanceToRight)
            if minDistance == distanceToLeft {
                snapPoint.x = bounds.minX
            } else if minDistance == distanceToRight {
                snapPoint.x = bounds.maxX
            }
        case .vertical:
            let minDistance = min(distanceToTop, distanceToBottom)
            if minDistance == distanceToTop {
                snapPoint.y = bounds.minY
            } else if minDistance == distanceToBottom {
                snapPoint.y = bounds.maxY
            }
        case .all:
            // 找到最近的边
            let minDistance = min(
                distanceToLeft,
                distanceToRight,
                distanceToTop,
                distanceToBottom
            )
            // 根据最近的边进行吸边
            if minDistance == distanceToLeft {
                snapPoint.x = bounds.minX
            } else if minDistance == distanceToRight {
                snapPoint.x = bounds.maxX
            } else if minDistance == distanceToTop {
                snapPoint.y = bounds.minY
            } else if minDistance == distanceToBottom {
                snapPoint.y = bounds.maxY
            }
        }

        return snapPoint
    }

    private func getGesture(geometry: GeometryProxy) -> some Gesture {
        DragGesture()
            .updating($isDragging) { value, state, transaction in
                state = true
            }
            .onChanged { value in
                // 确保 contentSize 已经获取
                guard contentSize != .zero else { return }
                
                // 在第一次 onChanged 时初始化
                if dragStartOffset == .zero {
                    dragStartOffset = currentPoint
                }

                let bounds = getBounds(geometry: geometry)
                let newPoint = CGPoint(
                    x: dragStartOffset.x + value.translation.width,
                    y: dragStartOffset.y + value.translation.height
                )
                // 应用边界限制
                currentPoint = clampToBounds(
                    newPoint,
                    geometry: geometry,
                    bounds: bounds
                )
            }
            .onEnded { value in
                // 确保 contentSize 已经获取
                guard contentSize != .zero else { return }
                
                let currentStartOffset = dragStartOffset
                let bounds = getBounds(geometry: geometry)

                let newPoint = CGPoint(
                    x: currentStartOffset.x + value.translation.width,
                    y: currentStartOffset.y + value.translation.height
                )
                // 应用边界限制
                let clampedPoint = clampToBounds(
                    newPoint,
                    geometry: geometry,
                    bounds: bounds
                )

                // 如果启用吸边效果,计算吸边位置并添加动画
                if enableSnapToEdge {
                    let snapPoint = calculateSnapPosition(
                        clampedPoint,
                        bounds: bounds,
                        snapEdgeAlignment: snapEdgeAlignment
                    )
                    withAnimation(.easeOut(duration: snapAnimationDuration)) {
                        currentPoint = snapPoint
                    }
                } else {
                    currentPoint = clampedPoint
                }

                // 重置拖拽状态
                dragStartOffset = .zero
            }
    }
}

// PreferenceKey 用于更可靠地获取 content 尺寸
struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}

Giscus 未启用:请在 src/site.config.ts 中配置 repoId 与 categoryId。