UIDex

الورقة السفلية

Bottom Sheet

BottomSheetDialog / .presentationDetents

اسمه كمانبوتوم شيت، شيت، اللوحة السفلية، اللوح المنزلق من الأسفل، القائمة السفلية، الدرج السفلي، sheet، modal bottom sheet، half sheet، slide-up panel، bottom drawer، pull-up panel

الورقة السفلية سطح ملتصق بالحافّة السفلية للشاشة، يصعد فوق المحتوى ويستقرّ عند ارتفاع من ارتفاعات معدودة، ويستجيب لسحب الإصبع صعوداً وهبوطاً. يقوم تعريفها على شيئين معاً: الحافّة التي تنتمي إليها، وارتفاعها الذي يملكه المستخدم لا المصمّم وحده. ولهذا هي ليست نافذة حوارية (modal dialog) تتوسّط المنطقة المرئية ولا تنتمي إلى حافّة ولا تُسحب ومقياسها الوحيد هو الحَجْب؛ وليست ورقة إجراءات (action sheet)، وهي قائمة أوامر قصيرة أصلها iOS بلا نقاط توقّف ولا محتوى حرّ؛ وليست درجاً (drawer) يخرج من حافّة جانبية فيتحرّك على المحور المضمّن وينعكس مع اتجاه الواجهة. والفارق الحاسم عن الدرج أن الورقة تتحرّك على المحور الكتلي وحده، فاتجاه حركتها لا ينعكس أبداً. وما إن يدخل ورقةَ الإجراءات نموذجٌ أو خريطةٌ أو قائمةٌ طويلة حتى يصير اسمها الصحيح ورقة سفلية. وفي داخل العنصر نفسه فرق ثانٍ لا يقلّ أهمّية: الورقة الحاجبة لها طبقة تعتيم وتُجمّد ما تحتها حتى تُغلق، أمّا الملازمة (persistent) فتبقى ظاهرة بلا تعتيم ويظلّ التطبيق تحتها قابلاً للاستعمال. والفرق بينهما سطر واحد في الكود: presentationBackgroundInteraction على iOS، و BottomSheetScaffold بدل ModalBottomSheet على أندرويد.

لو قلت عليه…

«اللوحة اللي بتطلع من تحت»«الحتّة اللي بتسحبها لفوق من آخر الشاشة»«القائمة اللي بتفتح من تحت وفيها الخيارات»«نصّ شاشة بتغطّي نصّ التطبيق»«الخطّ الرمادي الصغير اللي بتشدّ منه اللوحة»

العيّنة الحيّة

تفاعل مع الديمو. كل الأجزاء حقيقية ومرقّمة.

٩:٤١

المطاعم القريبة

١٢ مكاناً حولك

RTLاسحب المقبض. الحركة رأسية فلا تنعكس، لكن «إلغاء / تمّ» ينعكسان

تشريح العنصر: كل جزء واسمه

مرّر على أي سطر ليتحدّد مكانه في الديمو فوق.

برومبت البناء

ابنِ ورقة سفلية (bottom sheet) لتطبيق موبايل بنقطتَي توقّف: نصفية وكاملة. اجعلها حاجبة افتراضياً بطبقة تعتيم تبتلع اللمسات، مع role="dialog" و aria-modal="true" على الويب أو ModalBottomSheet على أندرويد و ‎.sheet مع ‎.presentationDetents([.medium, .large]) على iOS، وأتِح تحويلها إلى ملازمة بمفتاح واحد (presentationBackgroundInteraction أو BottomSheetScaffold) من دون إعادة كتابة المحتوى. ثبّتها بـ inset-block-end: 0 و inset-inline: 0، ودوّر زاويتيها العلويتين بـ border-start-start-radius و border-start-end-radius لا بـ border-top-left-radius. ضع مقبضاً في الأعلى متمركزاً بـ flex و justify-content: center، لا بـ inset-inline-start: 50% مع translateX. واجعل مساحة لمسه ٤٤ نقطة، وأضف بجانبه زرّ إغلاق حقيقياً باسم مقروء لأن السحب ليس متاحاً للجميع. اجعل الرأس صفّاً بترتيب DOM ثابت (إلغاء ثم تمّ) مع justify-content: space-between بلا row-reverse. اقصر التمرير على منطقة المحتوى وحدها مع overscroll-behavior: contain، وسلّم الإيماءة بشكل صحيح: السحب يحرّك الورقة ما دام scrollTop يساوي صفراً، وبعد بدء التمرير الداخلي تتجمّد الورقة حتى تعود القائمة إلى أعلاها. احسب الارتفاع بوحدات dvh لا vh، وأضف padding-block-end: env(safe-area-inset-bottom) للتذييل مع viewport-fit=cover، وارفع الورقة فوق لوحة المفاتيح عبر imePadding() أو visualViewport بدل تركها تحتها. استخدم الخصائص المنطقية في كل مكان: padding-inline و margin-inline و inset-inline و text-align: start، ولا تكتب left أو right إلا في موضع واحد هو حواف الأمان الجانبية env(safe-area-inset-left) و env(safe-area-inset-right) فهي فيزيائية بطبعها ولا مقابل منطقيّ لها، فاتركها كما هي ولا تقلبها في RTL. ولا تضف أي قاعدة اتجاه للحركة الرأسية. أغلقها بالسحب لأسفل وبزرّ الرجوع في أندرويد و Escape على الويب والنقر على الطبقة الحاجبة، وأعِد التركيز بعد الإغلاق إلى العنصر الذي فتحها، واحترم prefers-reduced-motion.

سلوكه في الاتجاه من اليمين لليسار

الجزء ده حصري عندنا.

المقبض لا ينعكس

لا ينعكس

المقبض متمركز أفقياً، وشكله متماثل حول محوره الرأسي، فليس فيه ما ينعكس. الفخّ في طريقة التمركز لا في الشكل: inset-inline-start: 50% مع transform: translateX(-50%) تبدو سليمة لأنها تعمل في LTR، لكنها تنكسر في RTL لأن الخاصّية المنطقية صارت تقيس من اليمين بينما translateX ظلّ فيزيائياً، فينزاح المقبض بمقدار عرضه كاملاً إلى الجهة الخطأ. ضَعْه في المنتصف بحاوية flex فيها justify-content: center، أو بـ margin-inline: auto، واترك التموضع المطلق كلّه.

صفّ إجراءات الرأس

ينعكس

زوج «إلغاء / تمّ» في رأس الورقة يتبادل الطرفين: إلغاء عند البداية المنطقية وتمّ عند النهاية، فيكون إلغاء يميناً في العربية ويساراً في الإنجليزية. ثبّت ترتيب الـ DOM (إلغاء أوّلاً) واستخدم justify-content: space-between فينعكس الصفّ وحده مع direction، ولا تكتب flex-direction: row-reverse داخل [dir="rtl"] لأن القلب سيقع مرّتين وينفصل الترتيب البصري عن ترتيب التنقّل بلوحة المفاتيح أو بـ VoiceOver. وفي SwiftUI حافّتا leading و trailing منطقيّتان تنعكسان وحدهما متى ورثت الواجهة layoutDirection = .rightToLeft، والذي لا ينعكس هو ‎.offset(x:) لأنه إزاحة فيزيائية، والبديل عنه حشو أو محاذاة.

حواف الأمان فيزيائية

لا ينعكس

الدالّتان env(safe-area-inset-left) و env(safe-area-inset-right) تصفان حافّتَي الجهاز نفسه، النتوء وفتحة الكاميرا وانحناء الزجاج، ولا مقابل منطقيّ لهما في CSS، فلا وجود لشيء اسمه env(safe-area-inset-inline-start). ومن يمشّط الملفّ ليستبدل كل left و right بنظيرتها المنطقية يقلب هاتين معهما، فيدفع محتوى الورقة تحت النتوء لحظة يدور الجهاز أفقياً. اتركهما فيزيائيتين، وادمج حشوك أنت في التعبير نفسه: padding-left: max(1rem, env(safe-area-inset-left)) ومثلها لـ padding-right، فلو كتبت padding-inline بعدهما دهس الإعلانُ المتأخّر قيمةَ الأمان وعاد المحتوى إلى تحت النتوء. والقاعدة نفسها على أندرويد: WindowInsets.safeDrawing يسلّمك left و right بوصفهما جهتَي الجهاز لا بداية السطر ونهايته، فطبّقهما كما جاءا ولا تعكسهما بيدك.

السحب الرأسي محايد

لا ينعكس

كل حركة الورقة على المحور الكتلي: السحب صعوداً وهبوطاً، ونقاط التوقّف، وعتبة السرعة التي تقرّر الإغلاق. ولا شيء من ذلك يتأثّر بـ direction، فلا تكتب له أي قاعدة داخل [dir="rtl"] ولا تلمس translateY. والخطأ يقع عند من يعامل الورقة معاملة الدرج فيضيف سحباً أفقياً للإغلاق: ذاك محور مضمّن ينقلب معناه بين اللغتين، وعلى iOS يصطدم بإيماءة الرجوع التي موضعها الحافّة البادئة، وهي في الواجهة العربية الحافّة اليمنى. أبقِ الإغلاق على ثلاثة طرق محايدة: السحب لأسفل، وزرّ صريح، والنقر على الطبقة الحاجبة.

ما بداخل الورقة

ينعكس

انعكاس الورقة لا يعني أن ما بداخلها انعكس معها. على أندرويد لا ينعكس شيء أصلاً ما لم يكن android:supportsRtl="true" في الـ manifest، وبعدها لا بدّ من paddingStart و layout_marginStart بدل نظائرها اليسارية. وداخل الصفوف: الأيقونة القائدة والشيفرون يتبادلان الطرفين، والشيفرون نفسه اتجاهيّ فاقلبه بـ transform: scaleX(-1) داخل [dir="rtl"] أو بـ rtl:-scale-x-100 في Tailwind، أو خُذ الأيقونة من Icons.AutoMirrored في Compose؛ أمّا النجمة وأيقونات الحالة فلا تنعكس أبداً وقلبها يجعلها تبدو مكسورة. واضبط المحاذاة بـ text-align: start لا left، ولفّ الأسعار وأرقام الهواتف بـ <bdi> أو unicode-bidi: isolate حتى لا ينزلق رمز العملة إلى الطرف الخطأ من السطر العربي.

أسماؤه في الكود

كل سطر هو كلمة مكتبة واحدة عن نفس الشيء. خد السطر اللي بيكلّم مشروعك.

SwiftUI.presentationDetents([.medium, .large])من iOS 16 يحوّل أي ‎.sheet إلى ورقة بنقاط توقّف؛ ورفيقه ‎.presentationDragIndicator(.visible) يُظهر المقبض.
SwiftUI.presentationBackgroundInteraction(.enabled(upThrough: .medium))المفتاح الذي يجعل الورقة ملازمة عند النقطة الصغرى: التطبيق تحتها يبقى قابلاً للّمس.
UIKitUISheetPresentationControllerالطبقة الأدنى: detents و prefersGrabberVisible و largestUndimmedDetentIdentifier لضبط متى يظهر التعتيم.
Jetpack ComposeModalBottomSheet(sheetState = rememberModalBottomSheetState())حالاته SheetValue.PartiallyExpanded و Expanded؛ وللنسخة الملازمة استعمل BottomSheetScaffold بدلاً منه.
Material ComponentsBottomSheetBehavior.STATE_HALF_EXPANDEDنسخة الـ Views: halfExpandedRatio يحدّد النقطة الوسطى، و skipCollapsed و isDraggable يضبطان السلوك، ويغلّفها BottomSheetDialogFragment.
HTML<dialog>.showModal() + ::backdropأقرب تقريب على الويب: الطبقة العليا والتعتيم و Escape بالمجّان، أمّا نقاط التوقّف والسحب فتكتبها بنفسك.
CSSpadding-block-end: env(safe-area-inset-bottom)يُبعد زرّ التذييل عن شريط الصفحة الرئيسية، ولا يعمل إلا مع viewport-fit=cover في وسم viewport.

شوف كمان

آخر تحديث · 2026-08-19