Article / 2026/05/29

DrawerView 抽屉菜单

DrawerView 抽屉菜单 的技术笔记。


import Foundation
import SwiftUI

struct DrawerView<
    Content: View, LeftMenuView: View, RightMenuView: View,
    LeftBackground: View, RightBackground: View
>: View {

    ///左侧的侧拉菜单的宽度
    var leftSideMenuWidth: CGFloat = 200
    ///右侧的侧拉菜单的默认宽度
    var rightSideMenuWidth: CGFloat = 200
    ///是否启动安全区域
    var shouldIgnoreSafeArea: Bool = false

    var leftSideDuration: TimeInterval = 0.3
    var righSidetDuration: TimeInterval = 0.3
    
    /// `swipeTriggerX` 触摸屏幕打开菜单的触摸区域
    var swipeTriggerX: CGFloat = 100

    ///是否显示左侧的侧拉菜单
    @Binding var showLeftMenu: Bool
    ///是否显示右侧拉菜单
    @Binding var showRightMenu: Bool

    ///偏移x坐标
    @State private var offsetX: CGFloat = 0
    ///拖曳时的坐标标记
    @State private var lastOffsetX: CGFloat = 0

    ///主内容
    @ViewBuilder var content: (UIEdgeInsets) -> Content

    ///左侧的侧拉菜单
    @ViewBuilder var leftMenuVeiw: (UIEdgeInsets) -> LeftMenuView

    ///右侧的侧拉菜单
    @ViewBuilder var rightMenuView: (UIEdgeInsets) -> RightMenuView

    /// `leftBackground` 左背景
    @ViewBuilder var leftBackground: LeftBackground

    /// `rightBackground` 右背景
    @ViewBuilder var rightBackground: RightBackground

    /// `progress` 进度
    @State private var progress: CGFloat = 0

    /// `isDragging` 标记是否是拖曳的状态
    @GestureState private var isDragging: Bool = false

    //标记做菜单正在打开
    @State private var isLeftDragging: Bool = false
    ///标记是右菜单正在打开
    @State private var isRightDragging: Bool = false

    ///防止一个侧拉菜单关闭时, 同时一个侧拉菜单打开
    @State private var isMenuDragging: Bool = false

    var body: some View {
        let mainView = GeometryReader { inset in

            let size = inset.size
            let safeArea =
                (UIApplication.shared.connectedScenes.first as? UIWindowScene)?
                .keyWindow?.safeAreaInsets ?? .zero

            let width = size.width  //- inset.safeAreaInsets.leading - inset.safeAreaInsets.trailing
            let height = size.height

            ZStack {
                GeometryReader { area in
                    content(area.safeAreaInsets.toUIEdgeInsets())
                }.frame(width: width).overlay {
                    if progress > 0 {
                        Rectangle().fill(Color.black.opacity(progress * 0.2))
                            .onTapGesture {
                                if !isMenuDragging {
                                    withAnimation(
                                        .snappy(
                                            duration: showLeftMenu
                                                ? leftSideDuration
                                                : righSidetDuration)
                                    ) {
                                        resetSide()
                                    }
                                }
                            }.frame(width: width, height: height)
                    }
                }

                HStack(alignment: .center, spacing: 0) {

                    GeometryReader { _ in
                        leftMenuVeiw(safeArea)
                    }.frame(width: leftSideMenuWidth).contentShape(.rect)
                        .background(leftBackground)
                        .opacity(progress)

                    Color.clear.frame(width: width, height: height)

                    GeometryReader { _ in
                        rightMenuView(safeArea)
                    }.frame(width: rightSideMenuWidth).contentShape(.rect)
                        .background(rightBackground)
                        .opacity(progress)

                }.frame(
                    width: leftSideMenuWidth + width + rightSideMenuWidth
                ).offset(
                    x: offsetX + (rightSideMenuWidth - leftSideMenuWidth)
                        / 2
                ).modifier(
                    IgnoreSafeAreaModifier(shouldIgnore: shouldIgnoreSafeArea))

            }.frame(width: width, height: height).contentShape(.rect)
                .simultaneousGesture(dragGesture)

        }

        var modifiedView: AnyView

        if #available(iOS 17, *) {
            modifiedView = AnyView(
                mainView.onChange(of: showLeftMenu, initial: false) {
                    oldValue, newValue in
                    withAnimation(
                        .snappy(
                            duration: showLeftMenu
                                ? leftSideDuration : righSidetDuration)
                    ) {
                        if newValue {
                            showLeftSideBar()
                        } else {
                            resetSide()
                        }
                    }
                }.onChange(of: showRightMenu) { oldValue, newValue in

                    withAnimation(
                        .snappy(
                            duration: showLeftMenu
                                ? leftSideDuration : righSidetDuration)
                    ) {
                        if newValue {
                            showRightSideBar()
                        } else {
                            resetSide()
                        }
                    }

                }
            )

        } else {

            modifiedView = AnyView(
                mainView
                    .onChange(of: showLeftMenu) { newValue in
                        withAnimation(
                            .snappy(
                                duration: showLeftMenu
                                    ? leftSideDuration : righSidetDuration)
                        ) {
                            if newValue {
                                showLeftSideBar()
                            } else {
                                resetSide()
                            }
                        }
                    }.onChange(of: showRightMenu) { newValue in

                        withAnimation(
                            .snappy(
                                duration: showLeftMenu
                                    ? leftSideDuration : righSidetDuration)
                        ) {
                            if newValue {
                                showRightSideBar()
                            } else {
                                resetSide()
                            }
                        }
                    }
                    .onAppear {
                        if showLeftMenu != false {
                            showLeftMenu = false
                        }
                        if showRightMenu != false {
                            showRightMenu = false
                        }
                    }

            )
        }

        return Group {
            modifiedView
        }

    }

    var dragGesture: some Gesture {
        DragGesture().updating($isDragging) { _, out, _ in
            out = true
        }.onChanged { value in
            guard value.startLocation.x > 10 else { return }

            if showLeftMenu {
                let translationX =
                    isDragging
                    ? max(
                        min(
                            value.translation.width + lastOffsetX,
                            leftSideMenuWidth), 0.0) : 0
                isLeftDragging = true
                offsetX = translationX
                calculateLeftProgress()
                isMenuDragging = true

            } else if showRightMenu {
                let translationX =
                    isDragging
                    ? min(
                        max(
                            value.translation.width + lastOffsetX,
                            -rightSideMenuWidth), 0) : 0
                isRightDragging = true
                offsetX = translationX
                calculateRightProgress()

                isMenuDragging = true
            } else {

                if isMenuDragging {
                    return
                }

                if value.startLocation.x < swipeTriggerX {
                    let translationX =
                        isDragging
                        ? max(
                            min(
                                value.translation.width + lastOffsetX,
                                leftSideMenuWidth), 0.0) : 0
                    isLeftDragging = true
                    offsetX = translationX
                    calculateLeftProgress()
                } else if value.startLocation.x > UIScreen.main.bounds.width - swipeTriggerX {

                    let translationX =
                        isDragging
                        ? min(
                            max(
                                value.translation.width + lastOffsetX,
                                -rightSideMenuWidth), 0) : 0
                    isRightDragging = true
                    offsetX = translationX
                    calculateRightProgress()

                }
            }

        }.onEnded { value in
            guard value.startLocation.x > 10 else { return }

            DispatchQueue.main.async {
                isMenuDragging = false
            }

            if showLeftMenu {
                withAnimation(
                    .snappy(
                        duration: showLeftMenu
                            ? leftSideDuration : righSidetDuration)
                ) {
                    let velocityX = value.velocity.width / 8
                    let total = velocityX + offsetX
                    if total > (leftSideMenuWidth * 0.5) {
                        showLeftSideBar()
                    } else {
                        resetSide()
                    }
                }
            } else if showRightMenu {

                withAnimation(
                    .snappy(
                        duration: showLeftMenu
                            ? leftSideDuration : righSidetDuration)
                ) {
                    let velocityX = value.velocity.width / 8
                    let total = velocityX + offsetX

                    if total < (-rightSideMenuWidth * 0.5) {
                        showRightSideBar()
                    } else {
                        resetSide()
                    }
                }

            } else {
                if value.startLocation.x < swipeTriggerX {
                    withAnimation(
                        .snappy(
                            duration: showLeftMenu
                                ? leftSideDuration : righSidetDuration)
                    ) {
                        let velocityX = value.velocity.width / 8
                        let total = velocityX + offsetX
                        if total > (leftSideMenuWidth * 0.5) {
                            showLeftSideBar()
                        } else {
                            resetSide()
                        }
                    }
                } else if value.startLocation.x > UIScreen.main.bounds.width - swipeTriggerX  {
                    withAnimation(
                        .snappy(
                            duration: showLeftMenu
                                ? leftSideDuration : righSidetDuration)
                    ) {
                        let velocityX = value.velocity.width / 8
                        let total = velocityX + offsetX

                        if total < (-rightSideMenuWidth * 0.5) {
                            showRightSideBar()
                        } else {
                            resetSide()
                        }
                    }
                }
            }

        }
    }

    func showLeftSideBar() {
        offsetX = leftSideMenuWidth
        lastOffsetX = offsetX
        showLeftMenu = true
        isLeftDragging = false
        calculateLeftProgress()
    }

    func showRightSideBar() {
        offsetX = -rightSideMenuWidth
        lastOffsetX = offsetX
        showRightMenu = true
        isRightDragging = false
        calculateRightProgress()
    }

    func calculateLeftProgress() {
        progress = max(min(offsetX / leftSideMenuWidth, 1.0), 0.0)
    }

    func calculateRightProgress() {
        progress = max(min(abs(offsetX / rightSideMenuWidth), 1.0), 0.0)
    }

    func resetSide() {
        offsetX = 0
        lastOffsetX = 0
        showLeftMenu = false
        isLeftDragging = false
        showRightMenu = false
        isRightDragging = false
        calculateLeftProgress()
    }

}

struct IgnoreSafeAreaModifier: ViewModifier {

    let shouldIgnore: Bool

    func body(content: Content) -> some View {
        if shouldIgnore {
            content.ignoresSafeArea()
        } else {
            content
        }
    }

}

extension EdgeInsets {
    func toUIEdgeInsets() -> UIEdgeInsets {
        return UIEdgeInsets(
            top: self.top, left: self.leading, bottom: self.bottom,
            right: self.trailing)
    }
}

extension UIEdgeInsets {
    func toEdgeInsets() -> EdgeInsets {
        return EdgeInsets(
            top: self.top, leading: self.left, bottom: self.bottom,
            trailing: self.right)
    }
}

//
//  DrawerView.swift
//  AnimatedMenu
//
//  Created by yangbinbing on 2025/3/2.
//

import Foundation
import SwiftUI

struct DrawerView<
    Content: View,
    LeftMenuView: View,
    RightMenuView: View,
    LeftBackground: View,
    RightBackground: View
>: View {

    /// 左侧的侧拉菜单的宽度
    var leftSideMenuWidth: CGFloat = 200
    /// 右侧的侧拉菜单的默认宽度
    var rightSideMenuWidth: CGFloat = 200
    /// 是否启动安全区域
    var shouldIgnoreSafeArea: Bool = false

    var leftSideDuration: TimeInterval = 0.3
    var rightSideDuration: TimeInterval = 0.3

    /// `swipeTriggerX` 触摸屏幕打开菜单的触摸区域
    var swipeTriggerX: CGFloat = 30

    /// 是否显示左侧的侧拉菜单
    @Binding var showLeftMenu: Bool
    /// 是否显示右侧拉菜单
    @Binding var showRightMenu: Bool

    /// 左侧菜单偏移x坐标
    @State private var leftOffsetX: CGFloat = 0
    /// 右侧菜单偏移x坐标
    @State private var rightOffsetX: CGFloat = 0

    /// 左侧拖曳时的坐标标记
    @State private var lastLeftOffsetX: CGFloat = 0
    /// 右侧拖曳时的坐标标记
    @State private var lastRightOffsetX: CGFloat = 0

    /// 主内容
    @ViewBuilder var content: (UIEdgeInsets) -> Content

    /// 左侧的侧拉菜单
    @ViewBuilder var leftMenuVeiw: (UIEdgeInsets) -> LeftMenuView

    /// 右侧的侧拉菜单
    @ViewBuilder var rightMenuView: (UIEdgeInsets) -> RightMenuView

    /// `leftBackground` 左背景
    @ViewBuilder var leftBackground: LeftBackground

    /// `rightBackground` 右背景
    @ViewBuilder var rightBackground: RightBackground

    /// `leftProgress` 左侧进度
    @State private var leftProgress: CGFloat = 0
    /// `rightProgress` 右侧进度
    @State private var rightProgress: CGFloat = 0

    /// `isDragging` 标记是否是拖曳的状态
    @GestureState private var isDragging: Bool = false

    /// 标记左菜单正在拖动
    @State private var isLeftDragging: Bool = false
    /// 标记右菜单正在拖动
    @State private var isRightDragging: Bool = false

    /// 当前拖动的起始位置
    @State private var dragStartLocation: CGPoint = .zero

    @State private var contentSize: CGSize = .zero
    @State private var safeAreaInsets: UIEdgeInsets = .zero

    var body: some View {
        GeometryReader { geometry in

            Color.clear.onAppear {
                print(
                    "inset = \(geometry.safeAreaInsets) size = \(geometry.size)"
                )
            }
            let size = geometry.size
            let safeArea = geometry.safeAreaInsets.toUIEdgeInsets()

            ZStack {
                // 主内容层(最底层,保持不动)
                GeometryReader { area in
                    content(safeArea)
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)

                // 遮罩层(点击关闭菜单)
                if leftProgress > 0 || rightProgress > 0 {
                    Rectangle()
                        .fill(
                            Color.black.opacity(
                                max(leftProgress, rightProgress) * 0.3
                            )
                        )
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .onTapGesture {
                            withAnimation(
                                .spring(
                                    response: showLeftMenu
                                        ? leftSideDuration : rightSideDuration,
                                    dampingFraction: 0.8
                                )
                            ) {
                                if showLeftMenu {
                                    hideLeftMenu()
                                }
                                if showRightMenu {
                                    hideRightMenu()
                                }
                            }
                        }
                }

                ZStack {

                    HStack(spacing: 0) {
                        GeometryReader { area in
                            leftMenuVeiw(safeArea)
                        }
                        .frame(width: leftSideMenuWidth)
                        .background(leftBackground.allowsHitTesting(false))
                        .opacity(leftProgress)

                        Spacer()
                    }
                    .offset(x: -leftSideMenuWidth + leftOffsetX)

                    HStack(spacing: 0) {
                        Spacer()

                        GeometryReader { _ in
                            rightMenuView(safeArea)
                        }
                        .frame(width: rightSideMenuWidth)
                        .background(rightBackground.allowsHitTesting(false))
                        .opacity(rightProgress)
                    }
                    .offset(x: rightSideMenuWidth + rightOffsetX)

                }.clipped()

            }.onAppear {
                contentSize = size
                safeAreaInsets = safeArea
            }.onCombinChange(
                of: geometry.size,
                initial: false,
                perform: { oldValue, newValue in
                    contentSize = newValue
                }
            ).onCombinChange(
                of: geometry.safeAreaInsets,
                perform: { oldValue, newValue in
                    safeAreaInsets = newValue.toUIEdgeInsets()
                }
            )
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .padding(0.01)
            .modifier(
                IgnoreSafeAreaModifier(shouldIgnore: shouldIgnoreSafeArea)
            ).simultaneousGesture(dragGesture)

        }.onCombinChange(of: showLeftMenu, initial: false) {
            oldValue,
            newValue in
            withAnimation(
                .spring(
                    response: leftSideDuration,
                    dampingFraction: 0.8
                )
            ) {
                if newValue {
                    showLeftSideBar()
                } else {
                    hideLeftMenu()
                }
            }
        }.onCombinChange(of: showRightMenu, initial: false) {
            oldValue,
            newValue in
            withAnimation(
                .spring(
                    response: rightSideDuration,
                    dampingFraction: 0.8
                )
            ) {
                if newValue {
                    showRightSideBar()
                } else {
                    hideRightMenu()
                }
            }
        }
    }

    var dragGesture: some Gesture {
        DragGesture()
            .updating($isDragging) { _, out, _ in
                out = true
            }
            .onChanged { value in
                handleDragChanged(value)
            }
            .onEnded { value in
                handleDragEnded(value)
            }
    }

    func handleDragChanged(_ value: DragGesture.Value) {
        // 记录拖动起始位置
        if !isLeftDragging && !isRightDragging {
            dragStartLocation = value.startLocation
        }

        // 使用实际的内容宽度,考虑安全区域
        let effectiveWidth = contentSize.width
        let leftSafeArea = safeAreaInsets.left
        let rightSafeArea = safeAreaInsets.right

        let leftTriggerArea = leftSafeArea + swipeTriggerX
        let rightTriggerArea = effectiveWidth - rightSafeArea - swipeTriggerX

        if showRightMenu && showLeftMenu {
            let canDragLeft = value.translation.width < 0
            if canDragLeft {
                isLeftDragging = true

                let translationX = value.translation.width

                if showLeftMenu {
                    // 菜单已打开,允许向左拖动关闭
                    let newOffset = leftSideMenuWidth + translationX
                    leftOffsetX = max(0, min(newOffset, leftSideMenuWidth))
                } else {
                    // 菜单未打开,允许向右拖动打开
                    leftOffsetX = max(0, min(translationX, leftSideMenuWidth))
                }

                calculateLeftProgress()
            }

            let canDragRight = value.translation.width > 0

            if canDragRight {
                isRightDragging = true

                let translationX = value.translation.width

                if showRightMenu {
                    // 菜单已打开,允许向右拖动关闭
                    let newOffset = -rightSideMenuWidth + translationX
                    rightOffsetX = min(0, max(newOffset, -rightSideMenuWidth))
                } else {
                    // 菜单未打开,允许向左拖动打开
                    rightOffsetX = min(
                        0,
                        max(translationX, -rightSideMenuWidth)
                    )
                }

                calculateRightProgress()
            }

        } else {
            // 判断是否在左侧边缘区域开始拖动,或者左侧菜单已经打开
            let isInLeftTriggerArea = value.startLocation.x < leftTriggerArea
            let canDragLeft =
                (isInLeftTriggerArea || showLeftMenu) && !isRightDragging
                && !showRightMenu

            if canDragLeft {
                isLeftDragging = true

                let translationX = value.translation.width

                if showLeftMenu {
                    // 菜单已打开,允许向左拖动关闭
                    let newOffset = leftSideMenuWidth + translationX
                    leftOffsetX = max(0, min(newOffset, leftSideMenuWidth))
                } else {
                    // 菜单未打开,允许向右拖动打开
                    leftOffsetX = max(0, min(translationX, leftSideMenuWidth))
                }

                calculateLeftProgress()
            }

            // 判断是否在右侧边缘区域开始拖动,或者右侧菜单已经打开
            let isInRightTriggerArea = value.startLocation.x > rightTriggerArea
            let canDragRight =
                (isInRightTriggerArea || showRightMenu) && !isLeftDragging
                && !showLeftMenu

            if canDragRight {
                isRightDragging = true

                let translationX = value.translation.width

                if showRightMenu {
                    // 菜单已打开,允许向右拖动关闭
                    let newOffset = -rightSideMenuWidth + translationX
                    rightOffsetX = min(0, max(newOffset, -rightSideMenuWidth))
                } else {
                    // 菜单未打开,允许向左拖动打开
                    rightOffsetX = min(
                        0,
                        max(translationX, -rightSideMenuWidth)
                    )
                }

                calculateRightProgress()
            }
        }

    }

    func handleDragEnded(_ value: DragGesture.Value) {
        let velocityX = value.velocity.width / 8

        // 处理左侧菜单
        if isLeftDragging {
            withAnimation(
                .spring(response: leftSideDuration, dampingFraction: 0.8)
            ) {
                let total = velocityX + leftOffsetX

                if showLeftMenu {
                    // 如果菜单已打开,判断是否应该关闭
                    if total < leftSideMenuWidth * 0.7 || velocityX < -50 {
                        hideLeftMenu()
                    } else {
                        showLeftSideBar()
                    }
                } else {
                    // 如果菜单未打开,判断是否应该打开
                    if total > leftSideMenuWidth * 0.3 || velocityX > 50 {
                        showLeftSideBar()
                    } else {
                        hideLeftMenu()
                    }
                }
            }
            isLeftDragging = false
        }

        // 处理右侧菜单
        if isRightDragging {
            withAnimation(
                .spring(response: rightSideDuration, dampingFraction: 0.8)
            ) {
                let total = velocityX + rightOffsetX

                if showRightMenu {
                    // 如果菜单已打开,判断是否应该关闭
                    if total > -rightSideMenuWidth * 0.7 || velocityX > 50 {
                        hideRightMenu()
                    } else {
                        showRightSideBar()
                    }
                } else {
                    // 如果菜单未打开,判断是否应该打开
                    if total < -rightSideMenuWidth * 0.3 || velocityX < -50 {
                        showRightSideBar()
                    } else {
                        hideRightMenu()
                    }
                }
            }
            isRightDragging = false
        }
    }

    func showLeftSideBar() {
        leftOffsetX = leftSideMenuWidth
        lastLeftOffsetX = leftOffsetX
        showLeftMenu = true
        calculateLeftProgress()
    }

    func showRightSideBar() {
        rightOffsetX = -rightSideMenuWidth
        lastRightOffsetX = rightOffsetX
        showRightMenu = true
        calculateRightProgress()
    }

    func hideLeftMenu() {
        leftOffsetX = 0
        lastLeftOffsetX = 0
        showLeftMenu = false
        calculateLeftProgress()
    }

    func hideRightMenu() {
        rightOffsetX = 0
        lastRightOffsetX = 0
        showRightMenu = false
        calculateRightProgress()
    }

    func calculateLeftProgress() {
        leftProgress = max(min(leftOffsetX / leftSideMenuWidth, 1.0), 0.0)
    }

    func calculateRightProgress() {
        rightProgress = max(
            min(abs(rightOffsetX) / rightSideMenuWidth, 1.0),
            0.0
        )
    }
}

private struct IgnoreSafeAreaModifier: ViewModifier {

    let shouldIgnore: Bool

    func body(content: Content) -> some View {
        if shouldIgnore {
            content.ignoresSafeArea()
        } else {
            content
        }
    }

}

private struct OnChange<T: Equatable>: ViewModifier {

    let value: T
    let initial: Bool
    let action: (T, T) -> Void

    func body(content: Content) -> some View {
        if #available(iOS 17.0, *) {
            content.onChange(
                of: value,
                initial: initial,
                { oldValue, newValue in
                    action(oldValue, newValue)
                }
            )
        } else {
            content.onChange(
                of: value,
                perform: { newValue in
                    action(value, newValue)
                }
            )
        }
    }

}

extension View {
    func onCombinChange<T: Equatable>(
        of value: T,
        initial: Bool = false,
        perform: @escaping (T, T) -> Void
    ) -> some View {
        modifier(
            OnChange(value: value, initial: initial, action: perform)
        )
    }
}

extension EdgeInsets {
    func toUIEdgeInsets() -> UIEdgeInsets {
        return UIEdgeInsets(
            top: self.top,
            left: self.leading,
            bottom: self.bottom,
            right: self.trailing
        )
    }
}

extension UIEdgeInsets {
    func toEdgeInsets() -> EdgeInsets {
        return EdgeInsets(
            top: self.top,
            leading: self.left,
            bottom: self.bottom,
            trailing: self.right
        )
    }
}

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