الرسوم المتحركة في Android: انتقالات سلسة للأجزاء داخل الورقة السفلية

تمت كتابة كمية كبيرة من الوثائق والمقالات حول مكون مرئي مهم للتطبيقات - الرسوم المتحركة. على الرغم من ذلك ، تمكنا من الوقوع في المشاكل وواجهنا عقبات في تنفيذه.



تتناول هذه المقالة المشكلة وتحليل الخيارات لحلها. لن أعطيك رصاصة فضية ضد كل الوحوش ، ولكن سأوضح لك كيف يمكنك دراسة رصاصة معينة من أجل إنشاء رصاصة خاصة به. سأقوم بتحليل هذا باستخدام مثال لكيفية جعل الرسوم المتحركة لتغيير الأجزاء تصبح صديقة للورقة السفلية.







الماس الخروج: الخلفية



Diamond Checkout هو الاسم الرمزي لمشروعنا. معناه بسيط للغاية - لتقليل الوقت الذي يقضيه العميل في المرحلة الأخيرة من الطلب. في حين أن الإصدار القديم يتطلب أربع نقرات على الأقل على شاشتين لتقديم طلب (وكل شاشة جديدة هي خسارة محتملة للسياق من قبل المستخدم) ، يتطلب "الدفع الماسي" بشكل مثالي نقرة واحدة فقط على شاشة واحدة.





مقارنة بين السحب القديم والجديد



نسمي الشاشة الجديدة "ستارة" بيننا. في الصورة يمكنك أن ترى كيف تلقينا المهمة من المصممين. حل التصميم هذا قياسي ، وهو معروف باسم الورقة السفلية ، الموصوفة في تصميم المواد (بما في ذلك Android) ويستخدم في اختلافات مختلفة في العديد من التطبيقات. تقدم لنا Google خيارين جاهزين للتنفيذ: مشروط ومستمر. وقد وصفت الفرق بين هذين النهجين في العديد ، العديد من المقالات.





قررنا أن ستارتنا ستكون مشروطة وكانت قريبة من نهاية سعيدة ، لكن فريق التصميم كان على حذره ولم يسمح بذلك.



ترى ما الرسوم المتحركة رهيبة على دائرة الرقابة الداخلية . فلنفعل الشيء نفسه؟



لم نتمكن من رفض هذا التحدي! حسنًا ، أنا أمزح فقط حول "قدم المصممون فجأة عرضًا لصناعة الرسوم المتحركة" ، لكن الجزء الخاص بـ iOS صحيح.



بدت التحولات القياسية بين الشاشات (أي غياب التحولات) ، على الرغم من أنها لم تكن خرقاء للغاية ، لكنها لم تصل إلى عنوان "الخروج الماسي". على الرغم من أنني أمزح ، كان الأمر فظيعًا حقًا:





ما لدينا "خارج الصندوق"



قبل الانتقال إلى وصف تنفيذ الرسوم المتحركة ، سأخبرك كيف بدت التحولات من قبل.



  1. قام العميل بالنقر على حقل العنوان الخاص بمطعم البيتزا -> استجابة لذلك ، تم فتح جزء "Pickup". تم فتحه في وضع ملء الشاشة (كما كان مقصودًا) مع قفزة حادة ، في حين ظهرت قائمة البيتزا مع تأخير طفيف.
  2. عندما ضغط العميل على "رجوع" -> حدثت العودة إلى الشاشة السابقة بقفزة حادة.
  3. عندما نقرت على حقل طريقة الدفع -> من الأسفل ، تم فتح جزء "طريقة الدفع" بقفزة حادة. ظهرت قائمة طرق الدفع بتأخير ؛ عندما ظهرت ، زادت الشاشة بقفزة.
  4. عندما تضغط "رجوع" -> ارجع بقفزة حادة.


يحدث التأخير في عرض البيانات بسبب تحميلها على الشاشة بشكل غير متزامن. سيكون من الضروري أخذ ذلك في الاعتبار في المستقبل.



في الواقع ، ما هي المشكلة: حيث يشعر العميل بالارتياح ، هناك قيود



لا يعجب المستخدمون عندما يكون هناك الكثير من الحركات المفاجئة على الشاشة. إنه مشتت ومربك. بالإضافة إلى ذلك ، فأنت تريد دائمًا رؤية استجابة سلسة لأفعالك ، وليس التشنجات.



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



هذا يعني أنه سيكون لدينا حاوية شظايا ديناميكية في الارتفاع (نظرًا لأن جميع الأجزاء لها ارتفاعات مختلفة) ، ويجب علينا تحريك تغيير ارتفاعها.



الترميز الأولي



عنصر الجذر في "الستارة" بسيط للغاية - إنها مجرد خلفية مستطيلة مع زوايا مستديرة في الأعلى ووعاء يتم وضع الشظايا فيه.



<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@drawable/dialog_gray200_background"
    >
 
  <androidx.fragment.app.FragmentContainerView
      android:id="@+id/container"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      />
 
</FrameLayout>


وملف Dial_gray200_background.xml يبدو كالتالي:



<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
  <item>
    <shape android:shape="rectangle">
      <solid android:color="@color/gray200" />
      <corners android:bottomLeftRadius="0dp" android:bottomRightRadius="0dp" android:topLeftRadius="10dp" android:topRightRadius="10dp" />
    </shape>
  </item>
</selector>


كل شاشة جديدة هي جزء منفصل ، يتم استبدال الأجزاء باستخدام طريقة الاستبدال ، كل شيء قياسي هنا.



المحاولات الأولى لتنفيذ الرسوم المتحركة



animateLayoutChanges



دعونا نتذكر animateLayoutChanges السحر القديم elven ، والذي هو في الواقع LayoutTransition الافتراضي. على الرغم من أن animateLayoutChanges غير مصمم لتغيير الأجزاء على الإطلاق ، إلا أنه يؤمل أن يساعد هذا في الرسوم المتحركة ذات الارتفاع. أيضًا لا يدعم FragmentContainerView animateLayoutChanges ، لذلك نغيره إلى FrameLayout القديم الجيد.



<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@drawable/dialog_gray200_background"
    >
 
  <FrameLayout
      android:id="@+id/container"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:animateLayoutChanges="true"
      />
 
</FrameLayout>


يركض:



animateLayoutChanges



كما ترى ، فإن تغيير ارتفاع الحاوية يكون متحركًا حقًا عند تغيير الأجزاء. يبدو أن الذهاب إلى شاشة Pickup جيدًا ، لكن الباقي يترك الكثير مما هو مرغوب فيه.



يقترح الحدس أن هذا المسار سيؤدي إلى ارتعاش المصمم ، لذلك نعيد تغييراتنا ونجرب شيئًا آخر.



setCustomAnimations



يتيح لك FragmentTransaction تعيين الرسوم المتحركة الموضحة بتنسيق xml باستخدام طريقة setCustomAnimation . للقيام بذلك ، في الموارد ، قم بإنشاء مجلد يسمى "anim" وأضف أربعة ملفات رسوم متحركة هناك:



to_right_out.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:toXDelta="100%" />
</set>


to_right_in.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:fromXDelta="-100%" />
</set>


to_left_out.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:toXDelta="-100%" />
</set>


to_left_in.xml



<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="500"
    android:interpolator="@android:anim/accelerate_interpolator"
>
  <translate android:fromXDelta="100%" />
</set>


ثم نضع هذه الرسوم المتحركة في معاملة:



fragmentManager
    .beginTransaction()
    .setCustomAnimations(R.anim.to_left_in, R.anim.to_left_out, R.anim.to_right_in, R.anim.to_right_out)
    .replace(containerId, newFragment)
    .addToBackStack(newFragment.tag)
    .commit()


نحصل على النتيجة التالية:





setCustomAnimation



ما لدينا مع هذا التطبيق:



  • لقد أصبح أفضل بالفعل - يمكنك أن ترى كيف تستبدل الشاشات بعضها البعض استجابةً لإجراءات المستخدم.
  • ولكن لا تزال هناك قفزة بسبب الارتفاعات المختلفة للشظايا. هذا يرجع إلى حقيقة أنه عند نقل الأجزاء في التسلسل الهرمي ، لا يوجد سوى جزء واحد. هو الذي يضبط ارتفاع الحاوية لنفسه ، والثاني يعرض "كيف حدث".
  • لا تزال هناك مشكلة في التحميل غير المتزامن للبيانات على طرق الدفع - تظهر الشاشة فارغة أولاً ، ثم تمتلئ بالمحتوى.


هذا ليس جيد. الخلاصة: أنت بحاجة إلى شيء آخر.



أو ربما جرب شيئًا مفاجئًا: انتقال العنصر المشترك



يعرف معظم مطوري Android عن انتقال العناصر المشتركة. ومع ذلك ، على الرغم من أن هذه الأداة مرنة للغاية ، إلا أن العديد من الأشخاص يواجهون مشاكل في استخدامها وبالتالي لا يحبونها كثيرًا.





جوهرها بسيط للغاية - يمكننا تحريك انتقال العناصر من جزء إلى آخر. على سبيل المثال ، يمكننا تحريك العنصر في الجزء الأول (دعنا نطلق عليه "العنصر الأولي") مع الرسوم المتحركة إلى مكان العنصر في الجزء الثاني (سوف نطلق على هذا العنصر "العنصر الأخير") ، بينما يتلاشى بقية عناصر الجزء الأول ويعرض الجزء الثاني مع التلاشي. العنصر الذي يحتاج إلى تحريك من جزء إلى آخر يسمى العنصر المشترك.



لتعيين العنصر المشترك ، نحتاج إلى:





ماذا لو كنت تستخدم طريقة العرض الجذرية للجزء كعنصر مشترك؟ ربما لم يتم اختراع انتقال العنصر المشترك لهذا الغرض. على الرغم من ذلك ، إذا فكرت في الأمر ، فمن الصعب إيجاد حجة لماذا لن يعمل هذا الحل. نريد تحريك عنصر البداية إلى عنصر النهاية بين جزأين. لا أرى أي تناقض أيديولوجي. دعنا نجرب هذا!



بالنسبة لكل جزء موجود داخل "الستارة" ، بالنسبة للعرض الجذر ، حدد سمة اسم الانتقال بنفس القيمة:



<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:transitionName="checkoutTransition"
    >


هام: سيعمل هذا لأننا نستخدم REPLACE في معاملة القطعة. إذا كنت تستخدم ADD (أو تستخدم ADD وإخفاء المقتطف السابق باستخدام previousFragment.hide () [لا تفعل هذا]) ، فسيتعين تعيين اسم الانتقال بشكل ديناميكي ومسحه بعد انتهاء الرسوم المتحركة. يجب القيام بذلك ، لأنه في وقت ما في التسلسل الهرمي لطريقة العرض الحالية لا يمكن أن يكون هناك طريقتي عرض بنفس الاسم الانتقالي. يمكن القيام بذلك ، ولكن سيكون من الأفضل إذا كنت تستطيع الاستغناء عن مثل هذا الاختراق. إذا كنت حقًا بحاجة إلى استخدام ADD ، فيمكنك العثور على الإلهام للتنفيذ في هذه المقالة.


بعد ذلك ، تحتاج إلى تحديد فئة الانتقال ، التي ستكون مسؤولة عن كيفية سير عملية الانتقال. أولاً ، دعنا نتحقق مما هو خارج الصندوق - استخدم النقل التلقائي .



newFragment.sharedElementEnterTransition = AutoTransition()


وعلينا تعيين العنصر المشترك الذي نريد تحريكه في معاملة الجزء. في حالتنا ، سيكون هذا هو العرض الجذري للجزء:



fragmentManager
    .beginTransaction()
    .apply{
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        addSharedElement(currentFragment.requireView(), currentFragment.requireView().transitionName)
        setReorderingAllowed(true)
      }
    }
    .replace(containerId, newFragment)
    .addToBackStack(newFragment.tag)
    .commit()


هام: يرجى ملاحظة أن TranscriptionName (مثل واجهة برمجة التطبيقات الانتقالية بالكامل) متاحة بدءًا من Android Lollipop.


دعونا نرى ما حدث:





نجحت عملية



النقل التلقائي ، لكنها تبدو كذلك. هذا لأنه أثناء معاملة القطعة ، تكون القطعة الجديدة فقط في التسلسل الهرمي للعرض. هذا الجزء يمتد أو يتقلص الحاوية إلى حجمها وبعد ذلك فقط يبدأ في تحريك باستخدام الانتقال. ولهذا السبب نرى الرسوم المتحركة فقط عندما يكون الجزء الجديد أعلى في الارتفاع من الجزء السابق.



بما أن التطبيق القياسي لا يناسبنا ، فماذا نفعل؟ بالطبع ، تحتاج إلى إعادة كتابة كل شيء في Flutter وكتابة انتقالك الخاص!



كتابة المرحلة الانتقالية الخاصة بك



الانتقال عبارة عن فئة من Transition API المسؤولة عن إنشاء الرسوم المتحركة بين مشهدين (Scene). العناصر الرئيسية لواجهة برمجة التطبيقات هذه:



  • المشهد هو ترتيب العناصر على الشاشة في وقت معين (تخطيط) ومجموعة العرض التي تتم فيها الرسوم المتحركة (sceneRoot).
  • المشهد الأول هو المشهد في وقت البدء.
  • المشهد النهائي هو المشهد في نقطة النهاية في الوقت المناسب.
  • الانتقال عبارة عن فئة تجمع خصائص مشاهد البداية والنهاية وإنشاء الرسوم المتحركة للتحريك فيما بينها.


سنستخدم أربع طرق في فئة الانتقال:



  • متعة getTransitionProperties (): صفيف. يجب أن تُرجع هذه الطريقة مجموعة من الخصائص التي سيتم تحريكها. من هذه الطريقة ، تحتاج إلى إرجاع مجموعة من السلاسل (المفاتيح) في شكل حر ، والشيء الرئيسي هو أن أساليب captStartValues ​​و captEndValues ​​(الموضحة أدناه) تكتب الخصائص باستخدام هذه المفاتيح. سيتبع مثال.
  • fun captureStartValues(transitionValues: TransitionValues). layout' . , , , .
  • fun captureEndValues(transitionValues: TransitionValues). , layout' .
  • fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator?. , , . , , .


Transition



  1. , Transition.



    @TargetApi(VERSION_CODES.LOLLIPOP)
    class BottomSheetSharedTransition : Transition {
    	@Suppress("unused")
    	constructor() : super()
     
    	@Suppress("unused")
    	constructor(
        	  context: Context?,
        	   attrs: AttributeSet?
    	) : super(context, attrs)
    }
    , Transition API Android Lollipop.
  2. getTransitionProperties.



    View, PROP_HEIGHT, ( ) :



    companion object {
      private const val PROP_HEIGHT = "heightTransition:height"
     
      private val TransitionProperties = arrayOf(PROP_HEIGHT)
    }
     
    override fun getTransitionProperties(): Array<String> = TransitionProperties
  3. captureStartValues.



    View, transitionValues. transitionValues.values ( Map) c PROP_HEIGHT:



    override fun captureStartValues(transitionValues: TransitionValues) {
      transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
    }


    , . , . , - . « » , , . , . :



    override fun captureStartValues(transitionValues: TransitionValues) {
      //    View...
      transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
     
      // ...      
      transitionValues.view.parent
        .let { it as? View }
        ?.also { view ->
            view.updateLayoutParams<ViewGroup.LayoutParams> {
                height = view.height
            }
        }
     
    }
  4. captureEndValues.



    , View. . . , . . , , , . — view, , . :



    override fun captureEndValues(transitionValues: TransitionValues) {
      //     View
      transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
    }


    getViewHeight:



    private fun getViewHeight(view: View): Int {
      //   
      val deviceWidth = getScreenWidth(view)
     
      //  View      
      val widthMeasureSpec = MeasureSpec.makeMeasureSpec(deviceWidth, MeasureSpec.EXACTLY)
      val heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
     
      return view
          // 
          .apply { measure(widthMeasureSpec, heightMeasureSpec) }
          //   
          .measuredHeight
          //  View       ,     
          .coerceAtMost(getScreenHeight(view))
    }
     
    private fun getScreenHeight(view: View) =
      getDisplaySize(view).y - getStatusBarHeight(view.context)
     
    private fun getScreenWidth(view: View) =
      getDisplaySize(view).x
     
    private fun getDisplaySize(view: View) =
      Point().also {
        (view.context.getSystemService(
            Context.WINDOW_SERVICE
        ) as WindowManager).defaultDisplay.getSize(it)
      }
     
    private fun getStatusBarHeight(context: Context): Int =
      context.resources
          .getIdentifier("status_bar_height", "dimen", "android")
          .takeIf { resourceId -> resourceId > 0 }
          ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) }
          ?: 0


    , , — .
  5. . Fade in.



    , . . «BottomSheetSharedTransition», :



    private fun prepareFadeInAnimator(view: View): Animator =
       ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f)
     
  6. . .



    , :



    private fun prepareHeightAnimator(
        startHeight: Int,
        endHeight: Int,
        view: View
    ) = ValueAnimator.ofInt(startHeight, endHeight)
        .apply {
            val container = view.parent.let { it as View }
            
            //    
            addUpdateListener { animation ->
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = animation.animatedValue as Int
                }
            }
        }


    ValueAnimator . , . , . , , . , WRAP_CONTENT. , :



    private fun prepareHeightAnimator(
        startHeight: Int,
        endHeight: Int,
        view: View
    ) = ValueAnimator.ofInt(startHeight, endHeight)
        .apply {
            val container = view.parent.let { it as View }
            
            //    
            addUpdateListener { animation ->
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = animation.animatedValue as Int
                }
            }
            
            //      WRAP_CONTENT 
            doOnEnd {
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = ViewGroup.LayoutParams.WRAP_CONTENT
                }
            }
        }


    , .
  7. . createAnimator.



    override fun createAnimator(
        sceneRoot: ViewGroup?,
        startValues: TransitionValues?,
        endValues: TransitionValues?
    ): Animator? {
        if (startValues == null || endValues == null) {
            return null
        }
     
        val animators = listOf<Animator>(
            prepareHeightAnimator(
                startValues.values[PROP_HEIGHT] as Int,
                endValues.values[PROP_HEIGHT] as Int,
                endValues.view
            ),
            prepareFadeInAnimator(endValues.view)
        )
     
        return AnimatorSet()
            .apply {
                interpolator = FastOutSlowInInterpolator()
                duration = ANIMATION_DURATION
                playTogether(animators)
            }
    }
  8. .



    Transititon'. , . , . «createAnimator» . ?



    • Fade' , .
    • «captureStartValues» , , WRAP_CONTENT.


    , . : , , Transition'. :



    companion object {
        private const val PROP_HEIGHT = "heightTransition:height"
        private const val PROP_VIEW_TYPE = "heightTransition:viewType"
     
        private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE)
    }
     
    override fun getTransitionProperties(): Array<String> = TransitionProperties
     
    override fun captureStartValues(transitionValues: TransitionValues) {
        //    View...
        transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
        transitionValues.values[PROP_VIEW_TYPE] = "start"
     
        // ...      
        transitionValues.view.parent
            .let { it as? View }
            ?.also { view ->
                view.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = view.height
                }
            }
     
    }
     
    override fun captureEndValues(transitionValues: TransitionValues) {
        //     View
        transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
        transitionValues.values[PROP_VIEW_TYPE] = "end"
    }
    


    , «PROP_VIEW_TYPE», «captureStartValues» «captureEndValues» . , !
  9. Transition.



    newFragment.sharedElementEnterTransition = BottomSheetSharedTransition()




لكي تبدأ الرسوم المتحركة في الوقت المناسب وتبدو جيدة ، تحتاج فقط إلى تأجيل الانتقال بين الأجزاء (وبالتالي الرسوم المتحركة) حتى يتم تحميل البيانات. للقيام بذلك ، قم باستدعاء طريقة postponeEnterTransition داخل الجزء . تذكر استدعاء startPostponedEnterTransition عندما تنتهي من مهام تحميل البيانات الطويلة . أنا متأكد من أنك تعرف هذه الحيلة ، ولكن لا يضر أن أذكرك مرة أخرى.



جميعًا معًا: ما حدث في النهاية



مع BottomSheetSharedTransition الجديد واستخدام postponeEnterTransition عند تحميل البيانات بشكل غير متزامن ، حصلنا على الرسوم المتحركة التالية:



انتقال جاهز



تحت المفسد هناك فئة BottomSheetSharedTransition الجاهزة
package com.maleev.bottomsheetanimation
 
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.animation.ValueAnimator
import android.annotation.TargetApi
import android.content.Context
import android.graphics.Point
import android.os.Build
import android.transition.Transition
import android.transition.TransitionValues
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.AccelerateInterpolator
import androidx.core.animation.doOnEnd
import androidx.core.view.updateLayoutParams
 
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
class BottomSheetSharedTransition : Transition {
 
    @Suppress("unused")
    constructor() : super()
 
    @Suppress("unused")
    constructor(
        context: Context?,
        attrs: AttributeSet?
    ) : super(context, attrs)
 
    companion object {
        private const val PROP_HEIGHT = "heightTransition:height"
 
        // the property PROP_VIEW_TYPE is workaround that allows to run transition always
        // even if height was not changed. It's required as we should set container height
        // to WRAP_CONTENT after animation complete
        private const val PROP_VIEW_TYPE = "heightTransition:viewType"
        private const val ANIMATION_DURATION = 400L
 
        private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE)
    }
 
    override fun getTransitionProperties(): Array<String> = TransitionProperties
 
    override fun captureStartValues(transitionValues: TransitionValues) {
        //    View...
        transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
        transitionValues.values[PROP_VIEW_TYPE] = "start"
 
        // ...      
        transitionValues.view.parent
            .let { it as? View }
            ?.also { view ->
                view.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = view.height
                }
            }
 
    }
 
    override fun captureEndValues(transitionValues: TransitionValues) {
        //     View
        transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
        transitionValues.values[PROP_VIEW_TYPE] = "end"
    }
 
    override fun createAnimator(
        sceneRoot: ViewGroup?,
        startValues: TransitionValues?,
        endValues: TransitionValues?
    ): Animator? {
        if (startValues == null || endValues == null) {
            return null
        }
 
        val animators = listOf<Animator>(
            prepareHeightAnimator(
                startValues.values[PROP_HEIGHT] as Int,
                endValues.values[PROP_HEIGHT] as Int,
                endValues.view
            ),
            prepareFadeInAnimator(endValues.view)
        )
 
        return AnimatorSet()
            .apply {
                duration = ANIMATION_DURATION
                playTogether(animators)
            }
    }
 
    private fun prepareFadeInAnimator(view: View): Animator =
        ObjectAnimator
            .ofFloat(view, "alpha", 0f, 1f)
            .apply { interpolator = AccelerateInterpolator() }
 
    private fun prepareHeightAnimator(
        startHeight: Int,
        endHeight: Int,
        view: View
    ) = ValueAnimator.ofInt(startHeight, endHeight)
        .apply {
            val container = view.parent.let { it as View }
 
            //    
            addUpdateListener { animation ->
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = animation.animatedValue as Int
                }
            }
 
            //      WRAP_CONTENT
            doOnEnd {
                container.updateLayoutParams<ViewGroup.LayoutParams> {
                    height = ViewGroup.LayoutParams.WRAP_CONTENT
                }
            }
        }
 
    private fun getViewHeight(view: View): Int {
        //   
        val deviceWidth = getScreenWidth(view)
 
        //  View      
        val widthMeasureSpec =
            View.MeasureSpec.makeMeasureSpec(deviceWidth, View.MeasureSpec.EXACTLY)
        val heightMeasureSpec =
            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
 
        return view
            // :
            .apply { measure(widthMeasureSpec, heightMeasureSpec) }
            //   :
            .measuredHeight
            //  View       ,     :
            .coerceAtMost(getScreenHeight(view))
    }
 
    private fun getScreenHeight(view: View) =
        getDisplaySize(view).y - getStatusBarHeight(view.context)
 
    private fun getScreenWidth(view: View) =
        getDisplaySize(view).x
 
    private fun getDisplaySize(view: View) =
        Point().also { point ->
            view.context.getSystemService(Context.WINDOW_SERVICE)
                .let { it as WindowManager }
                .defaultDisplay
                .getSize(point)
        }
 
    private fun getStatusBarHeight(context: Context): Int =
        context.resources
            .getIdentifier("status_bar_height", "dimen", "android")
            .takeIf { resourceId -> resourceId > 0 }
            ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) }
            ?: 0
}




عندما يكون لدينا فئة انتقالية جاهزة ، ينزل تطبيقها إلى خطوات بسيطة:



الخطوة 1. في معاملة جزء ، أضف عنصر مشترك وقم بتعيين الانتقال:



private fun transitToFragment(newFragment: Fragment) {
    val currentFragmentRoot = childFragmentManager.fragments[0].requireView()
 
    childFragmentManager
        .beginTransaction()
        .apply {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                addSharedElement(currentFragmentRoot, currentFragmentRoot.transitionName)
                setReorderingAllowed(true)
 
                newFragment.sharedElementEnterTransition = BottomSheetSharedTransition()
            }
        }
        .replace(R.id.container, newFragment)
        .addToBackStack(newFragment.javaClass.name)
        .commit()
}


الخطوة 2. في ترميز الأجزاء (الجزء الحالي والجزء التالي) ، والتي يجب أن تكون متحركة داخل BottomSheetDialogFragment ، قم بتعيين TransName:



<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:transitionName="checkoutTransition"
    >


هذا كل شيء ، النهاية.



كان من الممكن القيام به بشكل مختلف؟



هناك دائمًا العديد من الخيارات لحل المشكلة. أريد أن أذكر أساليب أخرى محتملة لم نجربها:



  • تخلص من الأجزاء ، واستخدم جزءًا واحدًا مع العديد من المشاهدات ، وحركة طرق عرض محددة. يمنحك هذا المزيد من التحكم في الرسوم المتحركة ، ولكنك تفقد فوائد الأجزاء: دعم التنقل الأصلي ومعالجة دورة الحياة الجاهزة (سيكون عليك تنفيذ ذلك بنفسك).
  • MotionLayout. MotionLayout , , , .
  • . , , . Bottom Sheet Bottom Sheet .
  • Bottom Sheet . — .
GitHub. Android- ( ) .




All Articles