AzureCommunicationUI/sdk/AzureCommunicationUICalling/Sources/Presentation/SwiftUI/ViewComponents/Drawer/ExpandableDrawer.swift (320 lines of code) (raw):

// // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. // import SwiftUI import FluentUI // MARK: - RoundedCorner Shape internal enum BottomDrawerHeightStatus { case collapsed case expanded } struct RoundedCorner: Shape { var radius: CGFloat = .infinity var corners: UIRectCorner = .allCorners // Allows the shape to animate smoothly var animatableData: CGFloat { get { radius } set { radius = newValue } } func path(in rect: CGRect) -> Path { let path = UIBezierPath( roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius) ) return Path(path.cgPath) } } // swiftlint:disable type_body_length internal struct ExpandableDrawer<Content: View>: View { // MARK: - Properties // Bindings @Binding var isPresented: Bool @Binding var isAutoCommitted: Bool // Drawer Configuration let hideDrawer: () -> Void let title: String? let endIcon: CompositeIcon? let endIconAction: (() -> Void)? let showTextBox: Bool let textBoxHint: String? let commitAction: ((_ message: String, _ isFinal: Bool?) -> Void)? let updateHeightAction: ((_ shouldExpand: Bool) -> Void)? let shouldExpand: Bool let endIconAccessibilityValue: String? let expandAccessibilityValue: String? let collapseAccessibilityValue: String? // Content let content: Content // Internal States @State private var drawerState: DrawerState = .gone @State private var drawerHeightState: BottomDrawerHeightStatus = .collapsed @State private var dragOffset: CGFloat = 0 @State private var drawerHeight: CGFloat = DrawerConstants.collapsedHeight @State private var text: String = "" @State private var keyboardHeight: CGFloat = 0 @State private var isFullyExpanded = false @State private var isExpanded = false // MARK: - Initializer init( isPresented: Binding<Bool>, hideDrawer: @escaping () -> Void, title: String? = nil, endIcon: CompositeIcon? = nil, endIconAction: (() -> Void)? = nil, endIconAccessibilityValue: String? = nil, showTextBox: Bool = false, shouldExpand: Bool = false, expandIconAccessibilityValue: String? = nil, collapseIconAccessibilityValue: String? = nil, textBoxHint: String? = nil, isAutoCommitted: Binding<Bool> = .constant(false), commitAction: ((_ message: String, _ isFinal: Bool?) -> Void)? = nil, updateHeightAction: ((_ shouldExpand: Bool) -> Void)? = nil, @ViewBuilder content: () -> Content ) { self._isPresented = isPresented self._isAutoCommitted = isAutoCommitted self.hideDrawer = hideDrawer self.title = title self.endIcon = endIcon self.endIconAction = endIconAction self.showTextBox = showTextBox self.shouldExpand = shouldExpand self.textBoxHint = textBoxHint self.commitAction = commitAction self.updateHeightAction = updateHeightAction self.content = content() self.endIconAccessibilityValue = endIconAccessibilityValue self.expandAccessibilityValue = expandIconAccessibilityValue self.collapseAccessibilityValue = collapseIconAccessibilityValue } // MARK: - Body var body: some View { ZStack(alignment: .bottom) { if drawerState != .gone { // Background Overlay overlayView // Drawer Content drawerView .transition(.move(edge: .bottom)) .offset(y: drawerState == .hidden ? UIScreen.main.bounds.height : max(dragOffset, 0)) .animation(.easeInOut, value: drawerState == .visible) .accessibilityAddTraits(.isModal) } } .onChange(of: isPresented) { newValue in newValue ? showDrawer() : hideDrawerAnimated() } .onChange(of: isAutoCommitted) { shouldClear in if shouldClear { clearText() } } .gesture( DragGesture() .onChanged { value in dragOffset = value.translation.height let newHeight = drawerHeight - value.translation.height drawerHeight = min(max(newHeight, DrawerConstants.collapsedHeight), DrawerConstants.expandedHeight) } .onEnded { value in withAnimation { drawerHeightState = value.translation.height < -DrawerConstants.dragThreshold ? .expanded : .collapsed isExpanded = drawerHeight > (DrawerConstants.collapsedHeight + DrawerConstants.expandedHeight) / 2 drawerHeight = isExpanded ? DrawerConstants.expandedHeight : DrawerConstants.collapsedHeight isFullyExpanded = drawerHeight == DrawerConstants.expandedHeight } dragOffset = 0 } ) .onAppear { if isPresented { showDrawer() } addKeyboardObservers() } .onDisappear { removeKeyboardObservers() } } // MARK: - Drawer View private var drawerView: some View { VStack { Spacer() VStack { // Handle for Dragging handleView // Title and Icons titleView // Content content // Text Editor (if applicable) if isFullyExpanded && showTextBox { textEditor .frame(height: DrawerConstants.textBoxHeight) .padding([.leading, .trailing], 10) } // Bottom Padding to Push Content Off-Screen Spacer().frame(height: DrawerConstants.textBoxPaddingBottom) } .frame(maxWidth: .infinity, alignment: .bottom) .background(Color(StyleProvider.color.drawerColor)) .clipShape(RoundedCorner(radius: DrawerConstants.drawerCornerRadius, corners: [.topLeft, .topRight])) .shadow(radius: DrawerConstants.drawerShadowRadius) .padding(.bottom, keyboardHeight) .animation(.easeInOut, value: keyboardHeight) .frame(height: drawerHeight) .accessibilityElement(children: .contain) .hideKeyboardOnTap() // Dismiss keyboard when tapping outside } .onAppear { if shouldExpand { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { setDrawerHeight(to: .expanded) updateHeightAction?(false) } } else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { setDrawerHeight(to: .collapsed) } } } } // MARK: - Handle View private var handleView: some View { RoundedRectangle(cornerRadius: DrawerConstants.drawerHandleCornerRadius) .fill(Color.gray.opacity(0.6)) .frame(width: 36, height: 4) .padding(.top, 5) } // MARK: - Title View private var titleView: some View { HStack(spacing: 8) { // Left Spacer (for balancing) Spacer() // Title centered using overlay Text(title ?? "") .font(.headline) .foregroundColor(.primary) .multilineTextAlignment(.center) .lineLimit(nil) .minimumScaleFactor(0.8) .frame(maxWidth: UIScreen.main.bounds.width * 0.6) // Prevents excessive width .layoutPriority(1) .accessibilityAddTraits(.isHeader) .frame(maxWidth: .infinity) .overlay( HStack(spacing: 8) { if let icon = endIcon { Icon(name: icon, size: DrawerListConstants.iconSize) .foregroundColor(Color(StyleProvider.color.drawerIconDark)) .onTapGesture { endIconAction?() } .accessibilityLabel(endIconAccessibilityValue ?? "") .accessibilityAddTraits(.isButton) } Icon(name: isFullyExpanded ? CompositeIcon.minimize : CompositeIcon.maximize, size: DrawerListConstants.iconSize) .foregroundColor(Color(StyleProvider.color.drawerIconDark)) .accessibilityLabel((isFullyExpanded ? collapseAccessibilityValue : expandAccessibilityValue) ?? "") .onTapGesture { setDrawerHeight(to: isFullyExpanded ? .collapsed : .expanded) } .accessibilityAddTraits(.isButton) }, alignment: .trailing // Ensures icons don’t push title off-center ) // Right Spacer (for balancing) Spacer() } .padding(.horizontal, 10) .padding(.top, 15) } // MARK: - Overlay View private var overlayView: some View { Color.black.opacity(isFullyExpanded ? DrawerConstants.overlayOpacity : 0) .ignoresSafeArea() .onTapGesture { if isFullyExpanded { setDrawerHeight(to: .collapsed) } } .accessibilityHidden(true) } // MARK: - Text Editor private var textEditor: some View { CustomTextField( text: $text, placeholder: textBoxHint ?? "", onCommit: { commitAction?(text, true) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { text = "" } }, onChange: { newText in commitAction?(newText, false) } ) .frame(height: DrawerConstants.textBoxHeight) .frame(maxWidth: .infinity) .onChange(of: isAutoCommitted) { shouldClear in if shouldClear { clearText() } } } // MARK: - Helper Functions /// Displays the drawer with animation. private func showDrawer() { drawerState = .hidden DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { withAnimation { drawerState = .visible } } } /// Hides the drawer with animation and sets state to gone after delay. private func hideDrawerAnimated() { withAnimation { drawerState = .hidden } DispatchQueue.main.asyncAfter(deadline: .now() + DrawerConstants.delayUntilGone) { drawerState = .gone } } /// Toggles the drawer between visible and hidden states. private func toggleDrawer() { if drawerState == .visible { hideDrawerAnimated() } else { showDrawer() } } /// Sets the drawer height to collapsed or expanded. /// - Parameter heightState: The desired height state. private func setDrawerHeight(to heightState: BottomDrawerHeightStatus) { withAnimation { drawerHeightState = heightState switch heightState { case .collapsed: drawerHeight = DrawerConstants.collapsedHeight isFullyExpanded = false case .expanded: drawerHeight = DrawerConstants.expandedHeight isFullyExpanded = true } } } /// Clears the text in the text editor and resets auto-commit state. private func clearText() { DispatchQueue.main.async { text = "" isAutoCommitted = false } } // MARK: - Keyboard Observers /// Adds observers for keyboard show and hide notifications. private func addKeyboardObservers() { NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { notification in if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { keyboardHeight = keyboardFrame.height if keyboardHeight > 0 { keyboardHeight -= 125 } } } NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in keyboardHeight = 0 } } /// Removes keyboard observers. private func removeKeyboardObservers() { NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) } } extension ExpandableDrawer { /// Determines if the device is in landscape mode based on width and height. private func isLandscape(geometry: GeometryProxy) -> Bool { return geometry.size.width > geometry.size.height } } extension View { /// A modifier to dismiss the keyboard when tapping anywhere on the screen. func hideKeyboardOnTap() -> some View { self.onTapGesture { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } } } // swiftlint:enable type_body_length