ZaatarLABS
تواصل معنا ←
→ كل المقالات
ZaatarLABS · هندسة

ما الذي كسرته نوافذ iOS 27 القابلة لتغيير الحجم في أربعة تطبيقات منشورة

نوافذ iPhone القابلة لتغيير الحجم تُبطل بصمت كل فحص لـ idiom في UIDevice يُستخدم للتخطيط — العطل نفسه، في أربع قواعد شيفرة، وإصلاح واحد بفئات الحجم.

5 دقائق قراءة

العطل الذي لم يكن في الخطة

وجدته أثناء العمل على تحديث The Smart Budget لـ iOS 27، لا في Budget نفسه — بل في SceneDelegate الخاص بـ The Smart Billing. كان ذلك الملف يقرأ UIDevice.current.userInterfaceIdiom == .pad مرة واحدة عند الإطلاق ويستخدم الإجابة ليقرّر هل يبني جذرًا بعرض مقسّم (split view) أم بشريط تبويبات.

يجعل iOS 27 تطبيقات iPhone قابلة لتغيير الحجم. وبمجرد أن يصبح ذلك صحيحًا، يتوقف فحص الـ idiom عن الإجابة عن السؤال الذي كان يُطرح عليه. إنه يخبرك على أي عتاد تعمل. لا يخبرك بعرض النافذة الآن. قد يُطلق المستخدم التطبيق بنافذة ضيقة — الـ idiom يقول .phone، فيُبنى شريط التبويبات — ثم يوسّع النافذة إلى حجم يستدعي عادةً شريطًا جانبيًا. لا يُعاد بناء الجذر أبدًا. فيرى شريط تبويبات في نافذة فيها متسع لعرض مقسّم.

دقّقتُ التطبيقات الأربعة كلها في المجموعة. وظهر النمط في كل واحد منها.

ما الذي كان الفحص يفعله

الافتراض المضمَّن في كل فحص idiom كان أن العتاد يحدد التخطيط. iPhone يعني عمودًا واحدًا. iPad يعني عمودين. قريب بما يكفي من الصحة لمدة طويلة بما يكفي كي لا يشكّك فيه أحد.

كان هذا المؤشر البديل خاطئًا أصلًا على Mac Catalyst. فـ UIScreen.main على Catalyst يُبلغ عن الشاشة الفعلية، لا عن نافذة التطبيق. كان The Smart Coach يحدّ قائمة نتائج البحث عند 55% من UIScreen.main.bounds.height. على شاشة كبيرة مع نافذة تطبيق صغيرة، قد يتجاوز ذلك الحدّ ارتفاع النافذة كلها. لم تكن العروض معطوبة بشكل واضح — بل كانت مُحجَّمة بصمت وفق الرقم الخطأ.

تكشف النوافذ القابلة لتغيير الحجم في iOS 27 الفئة نفسها من الأخطاء على iPhone.

إصلاح Billing

كان لدى The Smart Billing أكثف استخدام للـ idiom: قرار العرض المقسّم مقابل شريط التبويبات في SceneDelegate، وظهور زر الاتصال في موضعين، وثلاث قراءات لـ UIScreen.main لارتفاعات الأوراق المنبثقة (sheets) وتحجيم شريط ملحقات لوحة المفاتيح.

كان تغيير SceneDelegate الأكثر مساسًا بالبنية. تقرأ الشيفرة الجديدة horizontalSizeClass وتسجّل لتلقّي تغييرات السمات على النافذة:

window?.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { [weak self] in
    self?.updateRootViewControllerIfNeeded()
}

شرط ifNeeded مهم. تغيير الحجم يطلق هذا الاستدعاء باستمرار. ومن دون الشرط، كل بكسل من السحب يهدم مكدّس التنقل ويعيد بناء الجذر، فتضيع حالة التنقل. يتحقق الشرط من أن فئة الحجم انقلبت فعلًا قبل فعل أي شيء.

كان زر الاتصال مشكلة مختلفة. كان مخفيًا خلف userInterfaceIdiom != .pad، الذي كان يحاول الإجابة عن سؤال قدرة — هل يستطيع هذا الجهاز إجراء مكالمات هاتفية؟ — بإشارة تخطيط. كان هذا الاختبار الخطأ حتى قبل iOS 27. واستُبدل بـ canOpenURL(tel:)، وهو السؤال الفعلي.

كانت مواضع UIScreen.main أكثر آلية. انتقل حدّان لارتفاع الأوراق المنبثقة إلى containerRelativeFrame. وكان شريط ملحقات لوحة المفاتيح الرقمية يستدعي sizeToFit() أصلًا، فكان يكفي تهيئته بعرض صفري وتركه يقيس نفسه.

ثمانية اختبارات شاملة (end-to-end)، صفر إخفاقات على iPhone. بناء نظيف على Mac Catalyst.

إصلاح Coach

كانت لدى The Smart Coach حالة أدق في InvoiceFlowLayout. في تنفيذ لـ Layout.sizeThatFits، تعني القيمة الفارغة (nil) في proposal.width أن النظام يسأل «ما العرض الذي تريده؟» — لا «بعرض الشاشة». كانت الشيفرة القديمة تجيب بعرض الشاشة ثم تلفّ الصفوف وفق ذلك البُعد، الذي لم يُعطَ للتخطيط فعلًا قط.

الإصلاح: الاقتراح الفارغ يعني بلا قيد، أي صفًا واحدًا. أبلِغ عن العرض المستخدم فعلًا، لا عن عرض الشاشة.

أما حدّ النتائج في GlobalSearchView فكان أسهل رؤية:

// Before
.frame(maxHeight: UIScreen.main.bounds.height * 0.55)

// After — GeometryReader reads the view's actual container
resultsList(maxHeight: proxy.size.height * 0.55)

كان في الملف أصلًا GeometryReader لـ topPadding، يقرأ هامشًا آمنًا (safe-area inset) لا يتغير عند تغيير الحجم. احتاج حدّ النتائج إلى قارئ خاص به، لأن تغيير الحجم يجب أن يعيد تقييمه، والخاصية المحسوبة التي تُقرأ أثناء body ليس مضمونًا أن يُعاد تشغيلها.

ثمانية عشر اختبارًا شاملًا ناجحًا. بناء نظيف على Mac Catalyst.

ما تحتفظ به

لم يكن كل فحص idiom بحاجة إلى استبدال. في The Smart Coach عشرة منها. سبعة تحرس الوصول إلى الكاميرا إلى جانب فحص isSourceTypeAvailable الذي يحسم السؤال فعلًا. اثنان يخفيان زر الاتصال. وواحد يختار جذر العرض المقسّم في SceneDelegate، حيث ينطوي UISplitViewController أصلًا عند العرض المضغوط (compact).

بقيت العشرة كلها. وجود الكاميرا حقيقة عتادية. لا يستطيع تغيير حجم النافذة تغييرها. واستبدال userInterfaceIdiom بفئة حجم هناك سيكون إجابة عن السؤال الخطأ بأي رقم متاح.

القاعدة التي خرجت من هذا: فحوص الـ idiom مناسبة لأسئلة القدرة. وكان الـ idiom خاطئًا لأسئلة التخطيط. المشكلة أن أسئلة التخطيط صيغت كأسئلة قدرة لفترة طويلة جدًا بحيث لم يكن التمييز واضحًا في الشيفرة.

مشكلة Catalyst كانت موجودة أصلًا

كشف عمل iOS 27 الخطأ، لكن الخطأ أقدم من iOS 27. فـ UIScreen.main على Catalyst كان دائمًا يقيس الشاشة. وأي تطبيق استخدم UIScreen.main.bounds لتحجيم شيء ما، ثم عمل على Catalyst، كان يقيس الشيء الخطأ بصمت. كانت العروض منحرفة قليلًا فقط بطرق قد تبدو طبيعية.

إذا كنت تدعم Catalyst ولم تدقّق قراءات UIScreen.main لديك، فالخطأ موجود عندك اليوم.

ما كنت سأفعله بشكل مختلف

كنت سأقدّم نوعًا باسم DeviceCapability في بداية التطبيق الأول بدل ترك أسئلة القدرة تتراكم كفحوص idiom متناثرة في وحدات التحكم بالعروض. بحلول الوقت الذي كنتُ أنظر فيه إلى أربع قواعد شيفرة، كان منطق ظهور زر الاتصال قد نُسخ إلى موضعين في Billing وكُتب بشكل منفصل في Coach. كلاهما فحص userInterfaceIdiom. وكلاهما احتاج إلى التغيير.

خاصية canPlaceCalls مدعومة بـ canOpenURL(tel:) هي ثلاثة أسطر. ونسخ فحص idiom هو أيضًا ثلاثة أسطر، لكنه يجيب عن سؤال مختلف. ولا تلاحظ ذلك إلا عندما يتوقف الـ idiom عن كونه مؤشرًا بديلًا موثوقًا.

إلى القارئ

ابحث في قاعدة شيفرتك عن userInterfaceIdiom. قسّم النتائج إلى قرارات تخطيط وفحوص قدرة. قرارات التخطيط يجب أن تنتقل إلى فئات الحجم وregisterForTraitChanges. أما فحوص القدرة فغالبًا سليمة — لكن تأكد أن كل واحد منها يجيب عن سؤال عتادي، لا عن سؤال تخطيط متنكّر.

ثم ابحث عن UIScreen.main.bounds. إذا كنت تدعم Catalyst، فهذه القراءات خاطئة أصلًا. وسيكشف iOS 27 المشكلة نفسها على iPhone.

شرط registerForTraitChanges — أعد البناء فقط حين تتغير فئة الحجم فعلًا — هو الجزء الذي يسهل إغفاله. من دونه، يطلق السحب الاستدعاء عند كل نقطة، وقد تضيع حالة التنقل مع كل بكسل من تغيير الحجم. النظام لا يقوم بإخماد التكرار (debounce) نيابةً عنك.

بقلم Omar Al Homaidi · ZaatarLABS · @zaatarlabs