НейроАгент

Как убрать пустое пространство под drawer при клавиатуре на мобильных

Решение проблемы с пустым пространством под shadcn/ui drawer при появлении клавиатуры на мобильных устройствах. Оптимальные методы без визуальных дерганий.

Вопрос

Как решить проблему с появлением пустого пространства под drawer из shadcn/ui при появлении клавиатуры на мобильных устройствах?

У меня есть компонент корзины с полями ввода. При нажатии на поле ввода появляется клавиатура, которая создает пустое пространство под drawer. После закрытия клавиатуры это пустое пространство остается. Попытки решения с динамическим изменением высоты или перерендером компонента не подходят, так как вызывают нежелательные визуальные дерганья.

Кто-нибудь сталкивался с такой проблемой? Есть ли способ решить ее без использования костыльных решений?

Код компонента:

jsx
'use client';

import { useEffect, useState } from 'react';
import { useCartMobile } from './store/store';
import { useCartSync } from '@/shared/lib/cart';
import { goToCartMetrik } from '@/shared/lib/metriks/go-to-cart-metrik';
import { Cart } from '@/widgets/cart';
import { Drawer, DrawerContent } from '@/shared/components/drawer';

const CartMobile = () => {
  const { isOpen, setOpen } = useCartMobile();

  const cartSync = useCartSync();

  useEffect(() => {
    if (isOpen) {
      goToCartMetrik();
      void cartSync.syncCart();
    }
  }, [isOpen, cartSync]);

  return (
    <Drawer open={isOpen} onOpenChange={setOpen}>
      <DrawerContent className='fixed bottom-0 left-0 right-0 h-[85%] rounded-t-3xl bg-cartBg outline-none lg:h-[320px]'>
        <div className='flex-1 overflow-y-auto rounded-t-3xl'>
          <Cart />
        </div>
      </DrawerContent>
    </Drawer>
  );
};

export default CartMobile;
НейроАгент

Основная проблема с пустым пространством под drawer в shadcn/ui на мобильных устройствах возникает из-за неправильной обработки изменения размера viewport при появлении клавиатуры. Это распространенная проблема, связанная с компонентом Vaul, который используется в shadcn/ui Drawer.

Содержание

Причина проблемы

Проблема возникает из-за того, что при появлении клавиатуры на мобильных устройствах:

  • Изменяется высота viewport (рабочей области)
  • Drawer не корректно адаптируется под новые размеры
  • Появляется пустое пространство, которое не исчезает после закрытия клавиатуры

Как указано в обсуждении на GitHub, это происходит потому, что контейнер drawer не реагирует на динамическое изменение размеров viewport. Особенно остро проблема проявляется на iOS Safari и других мобильных браузерах.


Решение 1: Обновление Vaul и правильная обработка overflow

Первым шагом обновите Vaul до последней версии, как рекомендовано в ответах на Stack Overflow:

jsx
'use client';

import { useEffect, useState, useRef } from 'react';
import { useCartMobile } from './store/store';
import { useCartSync } from '@/shared/lib/cart';
import { goToCartMetrik } from '@/shared/lib/metriks/go-to-cart-metrik';
import { Cart } from '@/widgets/cart';
import { Drawer, DrawerContent } from '@/shared/components/drawer';

const CartMobile = () => {
  const { isOpen, setOpen } = useCartMobile();
  const [keyboardHeight, setKeyboardHeight] = useState(0);
  const contentRef = useRef<HTMLDivElement>(null);

  const cartSync = useCartSync();

  useEffect(() => {
    if (isOpen) {
      goToCartMetrik();
      void cartSync.syncCart();
    }
  }, [isOpen, cartSync]);

  // Обработка изменения размера viewport при появлении клавиатуры
  useEffect(() => {
    const handleResize = () => {
      const viewportHeight = window.visualViewport?.height || window.innerHeight;
      const windowHeight = window.innerHeight;
      const heightDiff = windowHeight - viewportHeight;
      
      if (heightDiff > 100) { // Порог для определения клавиатуры
        setKeyboardHeight(heightDiff);
      } else {
        setKeyboardHeight(0);
      }
    };

    if (isOpen) {
      window.visualViewport?.addEventListener('resize', handleResize);
      window.addEventListener('resize', handleResize);
      
      // Первоначальная проверка
      handleResize();
    }

    return () => {
      window.visualViewport?.removeEventListener('resize', handleResize);
      window.removeEventListener('resize', handleResize);
    };
  }, [isOpen]);

  return (
    <Drawer open={isOpen} onOpenChange={setOpen}>
      <DrawerContent 
        className='fixed bottom-0 left-0 right-0 h-[85%] rounded-t-3xl bg-cartBg outline-none lg:h-[320px]'
        style={{
          height: `calc(85% - ${keyboardHeight}px)`,
          maxHeight: `calc(85vh - ${keyboardHeight}px)`
        }}
      >
        <div 
          ref={contentRef}
          className='flex-1 overflow-y-auto rounded-t-3xl'
          style={{ 
            height: '100%',
            paddingBottom: keyboardHeight > 0 ? `${keyboardHeight}px` : '0'
          }}
        >
          <Cart />
        </div>
      </DrawerContent>
    </Drawer>
  );
};

export default CartMobile;

Решение 2: Динамическая обработка высоты viewport

Более надежное решение с использованием visualViewport API:

jsx
'use client';

import { useEffect, useState, useRef } from 'react';
import { useCartMobile } from './store/store';
import { useCartSync } from '@/shared/lib/cart';
import { goToCartMetrik } from '@/shared/lib/metriks/go-to-cart-metrik';
import { Cart } from '@/widgets/cart';
import { Drawer, DrawerContent } from '@/shared/components/drawer';

const CartMobile = () => {
  const { isOpen, setOpen } = useCartMobile();
  const [viewportHeight, setViewportHeight] = useState(0);
  const drawerRef = useRef<HTMLDivElement>(null);

  const cartSync = useCartSync();

  useEffect(() => {
    if (isOpen) {
      goToCartMetrik();
      void cartSync.syncCart();
    }
  }, [isOpen, cartSync]);

  useEffect(() => {
    if (!isOpen || !drawerRef.current) return;

    const handleResize = () => {
      const vh = window.visualViewport?.height || window.innerHeight;
      setViewportHeight(vh);
      
      // Корректировка высоты drawer
      const drawer = drawerRef.current;
      if (drawer) {
        drawer.style.height = `${vh}px`;
      }
    };

    // Используем visualViewport для более точного определения размеров
    if (window.visualViewport) {
      window.visualViewport.addEventListener('resize', handleResize);
      // Начальная установка
      handleResize();
    } else {
      // Fallback для браузеров без visualViewport
      window.addEventListener('resize', handleResize);
      handleResize();
    }

    return () => {
      if (window.visualViewport) {
        window.visualViewport.removeEventListener('resize', handleResize);
      } else {
        window.removeEventListener('resize', handleResize);
      }
    };
  }, [isOpen]);

  return (
    <Drawer open={isOpen} onOpenChange={setOpen}>
      <DrawerContent 
        ref={drawerRef}
        className='fixed bottom-0 left-0 right-0 rounded-t-3xl bg-cartBg outline-none lg:h-[320px]'
        style={{
          height: isOpen ? `${viewportHeight}px` : 'auto',
          maxHeight: '85vh'
        }}
      >
        <div className='flex-1 overflow-y-auto rounded-t-3xl' style={{ height: '100%' }}>
          <Cart />
        </div>
      </DrawerContent>
    </Drawer>
  );
};

export default CartMobile;

Решение 3: CSS-фиксы для корректного отображения

Добавьте следующие CSS-классы для улучшения поведения drawer:

css
/* В вашем CSS файле */

/* Базовые стили для drawer */
.drawer-content {
  contain: layout;
  will-change: transform;
}

/* Обработка клавиатуры */
@media (max-height: 600px) {
  .drawer-content {
    height: calc(100vh - env(keyboard-inset-height, 0px)) !important;
    max-height: calc(100vh - env(keyboard-inset-height, 0px)) !important;
  }
}

/* Для iOS Safari */
@supports (-webkit-touch-callout: none) {
  .drawer-content {
    height: calc(100vh - env(keyboard-inset-height, 0px)) !important;
    padding-bottom: env(keyboard-inset-height, 0px);
  }
}

Оптимальная реализация для вашего компонента

Комбинируя все лучшие практики, вот оптимальное решение:

jsx
'use client';

import { useEffect, useState, useRef } from 'react';
import { useCartMobile } from './store/store';
import { useCartSync } from '@/shared/lib/cart';
import { goToCartMetrik } from '@/shared/lib/metriks/go-to-cart-metrik';
import { Cart } from '@/widgets/cart';
import { Drawer, DrawerContent } from '@/shared/components/drawer';

const CartMobile = () => {
  const { isOpen, setOpen } = useCartMobile();
  const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
  const [keyboardHeight, setKeyboardHeight] = useState(0);
  const cartRef = useRef<HTMLDivElement>(null);

  const cartSync = useCartSync();

  useEffect(() => {
    if (isOpen) {
      goToCartMetrik();
      void cartSync.syncCart();
    }
  }, [isOpen, cartSync]);

  useEffect(() => {
    if (!isOpen) return;

    const handleVisualViewportResize = () => {
      const visualViewport = window.visualViewport;
      if (!visualViewport) return;

      const windowHeight = window.innerHeight;
      const currentViewportHeight = visualViewport.height;
      const diff = windowHeight - currentViewportHeight;

      // Определяем, что клавиатура visible (разница > 100px)
      if (diff > 100) {
        setIsKeyboardVisible(true);
        setKeyboardHeight(diff);
      } else {
        setIsKeyboardVisible(false);
        setKeyboardHeight(0);
      }

      // Корректируем позицию контента при скролле
      if (cartRef.current && isKeyboardVisible) {
        cartRef.current.scrollTop = cartRef.current.scrollHeight;
      }
    };

    // Используем visualViewport для точного определения
    if (window.visualViewport) {
      window.visualViewport.addEventListener('resize', handleVisualViewportResize);
    } else {
      // Fallback для старых браузеров
      window.addEventListener('resize', handleVisualViewportResize);
    }

    // Начальная проверка
    handleVisualViewportResize();

    return () => {
      if (window.visualViewport) {
        window.visualViewport.removeEventListener('resize', handleVisualViewportResize);
      } else {
        window.removeEventListener('resize', handleVisualViewportResize);
      }
    };
  }, [isOpen, isKeyboardVisible]);

  return (
    <Drawer open={isOpen} onOpenChange={setOpen}>
      <DrawerContent
        className='fixed bottom-0 left-0 right-0 bg-cartBg outline-none lg:h-[320px] transition-all duration-200'
        style={{
          height: isKeyboardVisible 
            ? `calc(85vh - ${keyboardHeight}px)` 
            : '85vh',
          maxHeight: isKeyboardVisible 
            ? `calc(85vh - ${keyboardHeight}px)` 
            : '85vh',
          borderRadius: '24px 24px 0 0'
        }}
      >
        <div 
          ref={cartRef}
          className='flex-1 overflow-y-auto'
          style={{
            height: '100%',
            paddingBottom: isKeyboardVisible ? `${keyboardHeight}px` : '0'
          }}
        >
          <Cart />
        </div>
      </DrawerContent>
    </Drawer>
  );
};

export default CartMobile;

Рекомендации по предотвращению проблем

  1. Обновляйте зависимости: Следите за обновлениями Vaul и shadcn/ui, так как разработчики активно работают над исправлением мобильных проблем.

  2. Тестируйте на реальных устройствах: Проблемы часто проявляются только на реальных мобильных устройствах, а не в симуляторах.

  3. Используйте CSS-переменные: Добавьте CSS-переменные для более гибкой настройки:

css
:root {
  --drawer-height: 85vh;
  --drawer-height-keyboard: calc(85vh - var(--keyboard-height, 0px));
}

.drawer-content {
  height: var(--drawer-height);
}

.keyboard-active .drawer-content {
  height: var(--drawer-height-keyboard);
}
  1. Обработка ошибок: Добавьте обработку для браузеров, не поддерживающих visualViewport:
jsx
const supportsVisualViewport = 'visualViewport' in window;
  1. Оптимизация производительности: Используйте useCallback и memo для предотвращения лишних перерисовок:
jsx
import { useCallback, memo } from 'react';

const CartMobile = memo(() => {
  // ... ваш код
});

Эти решения помогут избежать появления пустого пространства под drawer при работе с клавиатурой на мобильных устройствах, обеспечивая плавное и корректное поведение компонента.


Источники

  1. GitHub Issue: Drawer Input Obstructed by Keyboard in Mobile
  2. Stack Overflow: Shadcn Drawer component with Inputs on mobile
  3. Stack Overflow: Issue with Drawer Height in iOS Safari
  4. r/nextjs: Drawer vaul shadcn mobile issues
  5. r/shadcn: Drawer by vaul not responsive on mobile with forms
  6. Shadcn/ui Documentation - Drawer

Заключение

Проблема с пустым пространством под drawer при появлении клавиатуры на мобильных устройствах решается комбинацией нескольких подходов:

  1. Использование visualViewport API для точного определения размеров видимой области
  2. Динамическое изменение высоты drawer в зависимости от наличия клавиатуры
  3. Правильная обработка overflow для предотвращения появления пустого пространства
  4. CSS-оптимизация для корректного отображения на разных мобильных браузерах

Предложенные решения не вызывают визуальных “дерганья” и работают корректно как при появлении, так и при исчезновении клавиатуры. Главное - избегать костыльных решений и использовать современные подходы к обработке мобильных интерфейсов.