כדי ליצור הטמעות מלאות של התאמות אישיות של רכיבים בספריית ממשק המשתמש שברכב, אפשר להשתמש בפלאגינים של ספריית ממשק המשתמש שברכב במקום להשתמש בשכבות-על של משאבים בזמן ריצה (RRO). השימוש ב-RRO מאפשר לשנות רק את משאבי ה-XML של רכיבי ספריית ממשק המשתמש שברכב, ולכן יש מגבלות על ההתאמה האישית.
יצירת פלאגין
פלאגין של ספריית ממשק המשתמש שברכב הוא קובץ APK שמכיל מחלקות שמיישמות קבוצה של ממשקי API של פלאגינים. אפשר לקמפל את ממשקי ה-API של הפלאגין לפלאגין כספרייה סטטית.
דוגמאות ב-Soong וב-Gradle:
סונג
דוגמה לשימוש ב-Soong:
android_app {
name: "my-plugin",
min_sdk_version: "28",
target_sdk_version: "30",
aaptflags: ["--shared-lib"],
sdk_version: "current",
manifest: "src/main/AndroidManifest.xml",
srcs: ["src/main/java/**/*.java"],
resource_dirs: ["src/main/res"],
static_libs: [
"car-ui-lib-oem-apis",
],
// Disable optimization is mandatory to prevent R.java class from being
// stripped out
optimize: {
enabled: false,
},
certificate: ":my-plugin-certificate",
}
Gradle
אפשר לראות את הקובץ build.gradle
הזה:
apply plugin: 'com.android.application'
android {
compileSdkVersion 30
defaultConfig {
minSdkVersion 28
targetSdkVersion 30
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
signingConfigs {
debug {
storeFile file('chassis_upload_key.jks')
storePassword 'chassis'
keyAlias 'chassis'
keyPassword 'chassis'
}
}
}
dependencies {
implementation project(':oem-apis')
// Or use the following if you'd like to use the maven artifact
// implementation 'com.android.car.ui:car-ui-lib-plugin-apis:1.0.0'
}
Settings.gradle
:
// You can remove the ':oem-apis' if you're using the maven artifact.
include ':oem-apis'
project(':oem-apis').projectDir = new File('./path/to/oem-apis')
include ':my-plugin'
project(':my-plugin').projectDir = new File('./my-plugin')
במניפסט של הפלאגין צריך להיות מוצהר ספק תוכן עם המאפיינים הבאים:
android:authorities="com.android.car.ui.plugin"
android:enabled="true"
android:exported="true"
android:authorities="com.android.car.ui.plugin"
מאפשר לגלות את הפלאגין בספריית ממשק המשתמש שברכב. צריך לייצא את הספק כדי שאפשר יהיה לשלוח אליו שאילתות בזמן הריצה. בנוסף, אם המאפיין enabled
מוגדר לערך false
, המערכת תשתמש בהטמעה שמוגדרת כברירת מחדל במקום בהטמעה של הפלאגין. לא חובה שספק התוכן יהיה קיים. במקרה כזה, חשוב להוסיף את tools:ignore="MissingClass"
להגדרת הספק. דוגמה לרשומה במניפסט:
<application>
<provider
android:name="com.android.car.ui.plugin.PluginNameProvider"
android:authorities="com.android.car.ui.plugin"
android:enabled="false"
android:exported="true"
tools:ignore="MissingClass"/>
</application>
לבסוף, כאמצעי אבטחה, חותמים על האפליקציה.
תוספים כספרייה משותפת
בניגוד לספריות סטטיות של Android, שעוברות קומפילציה ישירות באפליקציות, ספריות משותפות של Android עוברות קומפילציה ל-APK עצמאי שאפליקציות אחרות מפנות אליו בזמן הריצה.
תוספים שמוטמעים כספרייה משותפת של Android, מוסיפים את המחלקות שלהם באופן אוטומטי לטוען המחלקות המשותף בין האפליקציות. כשבאפליקציה שמשתמשת בספריית ממשק המשתמש שברכב מוגדרת תלות בזמן ריצה בספרייה המשותפת של הפלאגין, טוען המחלקות שלה יכול לגשת למחלקות של הספרייה המשותפת של הפלאגין. תוספים שמיושמים כאפליקציות רגילות ל-Android (לא כספרייה משותפת) יכולים להשפיע לרעה על זמני ההפעלה הקרה של האפליקציה.
הטמעה ובנייה של ספריות משותפות
פיתוח באמצעות ספריות משותפות של Android דומה מאוד לפיתוח של אפליקציות רגילות ל-Android, אבל יש כמה הבדלים חשובים.
- משתמשים בתג
library
מתחת לתגapplication
עם שם חבילת הפלאגין במניפסט האפליקציה של הפלאגין:
<application>
<library android:name="com.chassis.car.ui.plugin" />
...
</application>
- מגדירים את כלל ה-build של Soong
android_app
(Android.bp
) באמצעות הדגל AAPTshared-lib
, שמשמש ליצירת ספרייה משותפת:
android_app {
...
aaptflags: ["--shared-lib"],
...
}
תלות בספריות משותפות
לכל אפליקציה במערכת שמשתמשת בספריית ממשק המשתמש שברכב, צריך לכלול את התג uses-library
במניפסט של האפליקציה מתחת לתג application
עם שם חבילת הפלאגין:
<manifest>
<application
android:name=".MyApp"
...>
<uses-library android:name="com.chassis.car.ui.plugin" android:required="false"/>
...
</application>
</manifest>
התקנת פלאגין
חובה להתקין מראש פלאגינים במחיצת המערכת על ידי הכללת המודול ב-PRODUCT_PACKAGES
. אפשר לעדכן את החבילה שהותקנה מראש באופן דומה לעדכון של כל אפליקציה אחרת שהותקנה.
אם מעדכנים תוסף קיים במערכת, כל האפליקציות שמשתמשות בתוסף הזה נסגרות באופן אוטומטי. אחרי שהמשתמש פותח מחדש את הקובץ, השינויים המעודכנים מופיעים בו. אם האפליקציה לא פעלה, בפעם הבאה שהיא תופעל היא תכלול את התוסף המעודכן.
כשמתקינים פלאגין באמצעות Android Studio, יש כמה דברים נוספים שצריך לקחת בחשבון. בזמן כתיבת המאמר הזה, יש באג בתהליך ההתקנה של אפליקציית Android Studio שגורם לכך שעדכונים לתוסף לא נכנסים לתוקף. כדי לפתור את הבעיה, צריך לבחור באפשרות Always install with package manager (disables deploy optimizations on Android 11 and later) במבנה של הפלאגין.
בנוסף, במהלך התקנת הפלאגין, Android Studio מדווח על שגיאה שלפיה הוא לא יכול למצוא פעילות ראשית להפעלה. זה צפוי, כי לתוסף אין פעילויות (חוץ מאובייקט ה-Intent הריק שמשמש לפתרון Intent). כדי לבטל את השגיאה, משנים את האפשרות Launch (הפעלה) לNothing (שום דבר) בהגדרות ה-build.
איור 1. הגדרת פלאגין Android Studio
פלאגין ל-Proxy
כדי להתאים אישית אפליקציות באמצעות ספריית ממשק המשתמש שברכב, צריך RRO שמטרגט כל אפליקציה ספציפית שרוצים לשנות, גם אם ההתאמות האישיות זהות באפליקציות שונות. המשמעות היא שנדרש RRO לכל אפליקציה. אפשר לראות באילו אפליקציות נעשה שימוש בספריית ממשק המשתמש לרכב.
הפלאגין של ספריית ממשק המשתמש שברכב הוא דוגמה לספרייה משותפת של פלאגין שמעבירה את יישומי הרכיבים שלה לגרסה הסטטית של ספריית ממשק המשתמש שברכב. אפשר לטרגט את הפלאגין הזה באמצעות RRO, שאפשר להשתמש בו כנקודה יחידה להתאמה אישית של אפליקציות שמשתמשות בספריית ממשק המשתמש שברכב, בלי צורך להטמיע פלאגין פונקציונלי. מידע נוסף על RRO זמין במאמר שינוי הערך של משאבי אפליקציה בזמן ריצה.
תוסף ה-proxy הוא רק דוגמה ונקודת התחלה להתאמה אישית באמצעות תוסף. כדי לבצע התאמה אישית מעבר ל-RRO, אפשר להטמיע קבוצת משנה של רכיבי פלאגין ולהשתמש בפלאגין של ה-proxy בשביל השאר, או להטמיע את כל רכיבי הפלאגין מאפס.
למרות שפלאגין ה-proxy מספק נקודה אחת להתאמה אישית של RRO לאפליקציות, אפליקציות שלא משתמשות בפלאגין עדיין ידרשו RRO שמכוון ישירות לאפליקציה עצמה.
הטמעה של ממשקי ה-API של הפלאגין
נקודת הכניסה הראשית לתוסף היא המחלקה com.android.car.ui.plugin.PluginVersionProviderImpl
. כל הפלאגינים חייבים לכלול מחלקה עם השם המדויק הזה ושם החבילה הזה. במחלקה הזו צריך להיות בנאי ברירת מחדל ולהטמיע את הממשק PluginVersionProviderOEMV1
.
יישומי פלאגין של CarUi צריכים לפעול עם אפליקציות שהן ישנות או חדשות יותר מהפלאגין. כדי להקל על התהליך הזה, כל ממשקי ה-API של התוספים הם בעלי גרסאות, עם V#
בסוף שם המחלקה שלהם. אם יוצאת גרסה חדשה של ספריית ממשק המשתמש שברכב עם תכונות חדשות,
הן חלק מגרסה V2
של הרכיב. ספריית ממשק המשתמש שברכב עושה כמיטב יכולתה כדי לגרום לתכונות חדשות לפעול במסגרת רכיב פלאגין ישן יותר.
לדוגמה, על ידי המרת סוג חדש של לחצן בסרגל הכלים ל-MenuItems
.
עם זאת, אפליקציה עם גרסה ישנה של ספריית ממשק המשתמש שברכב לא יכולה להתאים לפלאגין חדש שנכתב על בסיס ממשקי API חדשים יותר. כדי לפתור את הבעיה הזו, אנחנו מאפשרים לתוספים להחזיר הטמעות שונות של עצמם על סמך גרסת ה-OEM API שנתמכת על ידי האפליקציות.
PluginVersionProviderOEMV1
מכיל method אחת:
Object getPluginFactory(int maxVersion, Context context, String packageName);
השיטה הזו מחזירה אובייקט שמטמיע את הגרסה הכי גבוהה של PluginFactoryOEMV#
שנתמכת על ידי הפלאגין, ועדיין קטנה או שווה ל-maxVersion
. אם פלאגין לא כולל הטמעה של PluginFactory
מהגרסה הישנה, יכול להיות שהוא יחזיר null
. במקרה כזה, נעשה שימוש בהטמעה של רכיבי CarUi שמקושרים באופן סטטי.
כדי לשמור על תאימות לאחור עם אפליקציות שעברו קומפילציה מול גרסאות ישנות יותר של ספריית Car Ui סטטית, מומלץ לתמוך ב-maxVersion
s של 2, 5 ומעלה מתוך ההטמעה של התוסף של המחלקה PluginVersionProvider
. אין תמיכה בגרסאות 1, 3 ו-4. מידע נוסף זמין במאמר בנושא PluginVersionProviderImpl
.
PluginFactory
הוא הממשק שיוצר את כל רכיבי CarUi האחרים. היא גם מגדירה באיזו גרסה של הממשקים שלהם צריך להשתמש. אם הפלאגין לא מנסה להטמיע אף אחד מהרכיבים האלה, הוא עשוי להחזיר null
בפונקציית היצירה שלהם (למעט סרגל הכלים, שיש לו פונקציה נפרדת customizesBaseLayout()
).
הערך pluginFactory
מגביל את הגרסאות של רכיבי CarUi שאפשר להשתמש בהן יחד. לדוגמה, לא יכול להיות pluginFactory
שיכול ליצור גרסה 100 של Toolbar
וגם גרסה 1 של RecyclerView
, כי אין הרבה דרכים להבטיח שמגוון רחב של גרסאות של רכיבים יעבדו יחד. כדי להשתמש בסרגל הכלים בגרסה 100, המפתחים צריכים לספק הטמעה של גרסה של pluginFactory
שיוצרת סרגל כלים בגרסה 100, שמגביל את האפשרויות בגרסאות של רכיבים אחרים שאפשר ליצור. יכול להיות שהגרסאות של רכיבים אחרים לא יהיו זהות. לדוגמה, רכיב pluginFactoryOEMV100
יכול ליצור רכיב ToolbarControllerOEMV100
ורכיב RecyclerViewOEMV70
.
סרגל כלים
פריסת בסיס
סרגל הכלים ופריסת הבסיס קשורים מאוד זה לזה, ולכן הפונקציה שיוצרת את סרגל הכלים נקראת installBaseLayoutAround
. פריסת הבסיס היא קונספט שמאפשר למקם את סרגל הכלים בכל מקום מסביב לתוכן של האפליקציה, כדי לאפשר סרגל כלים בחלק העליון או התחתון של האפליקציה, אנכית לאורך הצדדים, או אפילו סרגל כלים מעגלי שמקיף את כל האפליקציה. כדי לעשות את זה, מעבירים תצוגה אל installBaseLayoutAround
כדי שסרגל הכלים או פריסת הבסיס יקיפו אותה.
התוסף צריך לקחת את התצוגה שסופקה, לנתק אותה מההורה שלה, להרחיב את הפריסה של התוסף באותו אינדקס של ההורה ועם אותו LayoutParams
כמו התצוגה שנותקה, ואז לצרף מחדש את התצוגה איפשהו בתוך הפריסה שהורחבה. הפריסה המורחבת תכלול את סרגל הכלים, אם האפליקציה מבקשת זאת.
האפליקציה יכולה לבקש פריסת בסיס ללא סרגל כלים. אם כן, הפונקציה installBaseLayoutAround
צריכה להחזיר ערך null. ברוב הפלאגינים, זה כל מה שצריך לקרות, אבל אם מחבר הפלאגין רוצה להחיל, למשל, קישוט מסביב לקצה האפליקציה, אפשר לעשות את זה באמצעות פריסת בסיס. הקישוטים האלה שימושיים במיוחד במכשירים עם מסכים לא מלבניים, כי הם יכולים להעביר את האפליקציה למרחב מלבני ולהוסיף מעברים חלקים למרחב הלא מלבני.
גם installBaseLayoutAround
מועבר Consumer<InsetsOEMV1>
. אפשר להשתמש בצרכן הזה כדי להעביר לאפליקציה את המידע שהתוסף מכסה חלקית את התוכן של האפליקציה (עם סרגל הכלים או בדרך אחרת). האפליקציה תדע להמשיך לצייר במרחב הזה, אבל לא לכלול בו רכיבים קריטיים שמשתמשים יכולים ליצור איתם אינטראקציה. האפקט הזה משמש בעיצוב ההפניה שלנו כדי להפוך את סרגל הכלים לחצי שקוף, וכדי שהרשימות יגללו מתחתיו. אם התכונה הזו לא הייתה מיושמת, הפריט הראשון ברשימה היה נתקע מתחת לסרגל הכלים ולא ניתן היה ללחוץ עליו. אם לא צריך את האפקט הזה, הפלאגין יכול להתעלם מה-Consumer.
איור 2. גלילת התוכן מתחת לסרגל הכלים
מנקודת המבט של האפליקציה, כשהפלאגין שולח תמונות ממוזערות חדשות, הוא מקבל אותן מכל הפעילויות או הקטעים שמטמיעים את InsetsChangedListener
. אם פעילות או מקטע לא מטמיעים את InsetsChangedListener
, ספריית Car Ui תטפל בתוספות כברירת מחדל על ידי החלת התוספות כריווח פנימי על Activity
או FragmentActivity
שמכילים את המקטע. כברירת מחדל, בספרייה לא מוחלים שוליים פנימיים על קטעים. הנה דוגמה לקטע קוד של הטמעה שבה המרווחים הפנימיים מוחלים כריווח פנימי ב-RecyclerView
באפליקציה:
public class MainActivity extends Activity implements InsetsChangedListener {
@Override
public void onCarUiInsetsChanged(Insets insets) {
CarUiRecyclerView rv = requireViewById(R.id.recyclerview);
rv.setPadding(insets.getLeft(), insets.getTop(),
insets.getRight(), insets.getBottom());
}
}
לבסוף, התוסף מקבל fullscreen
רמז, שמשמש לציון אם התצוגה שצריך לעטוף תופסת את כל האפליקציה או רק חלק קטן ממנה.
אפשר להשתמש בזה כדי להימנע מהחלת קישוטים מסוימים לאורך הקצה, שרלוונטיים רק אם הם מופיעים לאורך הקצה של המסך כולו. אפליקציית ההגדרות היא דוגמה לאפליקציה שמשתמשת בפריסות בסיסיות שלא מוצגות במסך מלא, שבה לכל חלונית בפריסה עם שתי חלוניות יש סרגל כלים משלה.
מצופה מהפונקציה installBaseLayoutAround
להחזיר ערך null כאשר toolbarEnabled
הוא false
. כדי שהתוסף יציין שהוא לא רוצה להתאים אישית את פריסת הבסיס, הוא צריך להחזיר את הערך false
מהפונקציה customizesBaseLayout
.
פריסת הבסיס חייבת להכיל רכיב FocusParkingView
ורכיב FocusArea
כדי לתמוך באופן מלא בפקדים סיבוביים. אפשר להשמיט את התצוגות האלה במכשירים שלא תומכים בחוגה. ה-FocusParkingView/FocusAreas
מיושמים בספריית CarUi הסטטית, ולכן נעשה שימוש ב-setRotaryFactories
כדי לספק מפעלים ליצירת התצוגות מההקשרים.
ההקשרים שמשמשים ליצירת תצוגות ממוקדות חייבים להיות הקשר המקור, ולא ההקשר של התוסף. הסימן FocusParkingView
צריך להיות הכי קרוב שאפשר לתצוגה הראשונה בעץ, כי הוא מה שמודגש כשלא אמורה להיות הדגשה גלויה למשתמש. התג FocusArea
צריך לתחום את סרגל הכלים בפריסת הבסיס כדי לציין שהוא אזור של תנועת חוגה. אם לא מציינים את FocusArea
, המשתמש לא יכול לנווט ללחצנים בסרגל הכלים באמצעות בקר סיבובי.
בקר סרגל הכלים
הפריסה בפועל שמוחזרת ToolbarController
אמורה להיות פשוטה יותר ליישום מהפריסה הבסיסית. התפקיד שלו הוא לקבל מידע שמועבר לפונקציות ה-setter שלו ולהציג אותו בפריסת הבסיס. מידע על רוב השיטות זמין ב-Javadoc. בהמשך מוסבר על כמה מהשיטות המורכבות יותר.
getImeSearchInterface
משמש להצגת תוצאות חיפוש בחלון IME (מקלדת).
האפשרות הזו יכולה להיות שימושית להצגה או להנפשה של תוצאות חיפוש לצד המקלדת, למשל אם המקלדת תופסת רק חצי מהמסך. רוב הפונקציונליות מיושמת בספריית CarUi הסטטית. ממשק החיפוש בפלאגין מספק רק שיטות לספרייה הסטטית כדי לקבל את הקריאות החוזרות TextView
ו-onPrivateIMECommand
. כדי לתמוך בזה, התוסף צריך להשתמש במחלקת משנה TextView
שמבטלת את onPrivateIMECommand
ומעבירה את הקריאה למאזין שסופק בתור TextView
של סרגל החיפוש.
setMenuItems
פשוט מציג MenuItems על המסך, אבל הוא ייקרא בתדירות גבוהה באופן מפתיע. ממשק ה-API של הפלאגין ל-MenuItems הוא בלתי ניתן לשינוי, ולכן בכל פעם שמשנים MenuItem, מתבצעת קריאה חדשה לגמרי ל-setMenuItems
. זה יכול לקרות במקרים פשוטים כמו משתמש שלחץ על MenuItem של מתג, והקליק הזה גרם להחלפת מצב המתג. לכן, מומלץ לחשב את ההבדל בין רשימת MenuItem הישנה לרשימת MenuItem החדשה, ולעדכן רק את התצוגות שהשתנו בפועל. רכיב MenuItems מספק שדה key
שיכול לעזור בכך, כי המפתח צריך להיות זהה בכל הקריאות השונות ל-setMenuItems
עבור אותו רכיב MenuItem.
AppStyledView
AppStyledView
הוא מאגר לתצוגה שלא מותאמת בכלל. אפשר להשתמש בה כדי לספק גבול מסביב לתצוגה הזו, כך שהיא תבלוט משאר האפליקציה, וכדי לציין למשתמש שמדובר בממשק מסוג אחר. התצוגה שמוקפת ב-AppStyledView מופיעה ב-
setContent
. יכול להיות שגם בסמל AppStyledView
יהיה לחצן 'הקודם' או 'סגירה', בהתאם לבקשת האפליקציה.
התג AppStyledView
לא מוסיף את התצוגות שלו להיררכיית התצוגות באופן מיידי כמו התג installBaseLayoutAround
, אלא רק מחזיר את התצוגה שלו לספרייה הסטטית דרך getView
, שמוסיף אותה. אפשר גם לשלוט במיקום ובגודל של AppStyledView
באמצעות הטמעה של getDialogWindowLayoutParam
.
הקשרים
הפלאגין צריך להיזהר כשמשתמשים בהקשרים, כי יש הקשרים של פלאגין וגם הקשרים של 'מקור'. ההקשר של הפלאגין מועבר כארגומנט ל-getPluginFactory
, והוא ההקשר היחיד שמכיל את המשאבים של הפלאגין. המשמעות היא שזה ההקשר היחיד שאפשר להשתמש בו כדי להגדיל את הפריסות בתוסף.
עם זאת, יכול להיות שההגדרה הנכונה לא מוגדרת בהקשר של התוסף. כדי לקבל את ההגדרה הנכונה, אנחנו מספקים הקשרים של מקור בשיטות שיוצרות רכיבים. הקשר של המקור הוא בדרך כלל פעילות, אבל במקרים מסוימים הוא יכול להיות גם שירות או רכיב אחר של Android. כדי להשתמש בהגדרה מהקשר של המקור עם המשאבים מהקשר של התוסף, צריך ליצור הקשר חדש באמצעות createConfigurationContext
. אם לא משתמשים בהגדרה הנכונה, תהיה הפרה של מצב Strict ב-Android, ויכול להיות שהתצוגות המורחבות לא יהיו בגודל הנכון.
Context layoutInflationContext = pluginContext.createConfigurationContext(
sourceContext.getResources().getConfiguration());
שינויים במצב
תוספים מסוימים יכולים לתמוך במספר מצבים לרכיבים שלהם, כמו מצב ספורט או מצב חיסכון, שונים מבחינה ויזואלית. אין תמיכה מובנית בפונקציונליות כזו ב-CarUi, אבל אין שום דבר שמונע מהפלאגין להטמיע אותה באופן פנימי לחלוטין. התוסף יכול לעקוב אחרי כל התנאים שרוצים כדי להבין מתי צריך להחליף מצבים, למשל להאזין לשידורים. התוסף לא יכול להפעיל שינוי בהגדרה כדי לשנות מצבים, אבל בכל מקרה לא מומלץ להסתמך על שינויים בהגדרות, כי עדכון ידני של המראה של כל רכיב הוא חלק יותר למשתמש, והוא גם מאפשר מעברים שלא אפשריים עם שינויים בהגדרות.
Jetpack פיתוח נייטיב
אפשר להטמיע תוספים באמצעות Jetpack Compose, אבל זו תכונה ברמת אלפא ולא מומלץ להסתמך עליה.
תוספים יכולים להשתמש ב-ComposeView
כדי ליצור משטח שמופעלת בו האפשרות 'כתיבה' לצורך עיבוד. הערך של ComposeView
יהיה הערך שמוחזר לאפליקציה מהשיטה getView
ברכיבים.
בעיה מרכזית אחת בשימוש ב-ComposeView
היא שהיא מגדירה תגים בתצוגת הבסיס בפריסה כדי לאחסן משתנים גלובליים שמשותפים בין רכיבי ComposeView שונים בהיררכיה. מכיוון שמזהי המשאבים של הפלאגין לא מופרדים ממרחב השמות של האפליקציה, יכולות להיווצר התנגשויות אם גם האפליקציה וגם הפלאגין מגדירים תגים באותה תצוגה. בהמשך מופיע קוד מותאם אישית
ComposeViewWithLifecycle
שמעביר את המשתנים הגלובליים האלה אל
ComposeView
. שוב, לא מדובר בגרסה יציבה.
ComposeViewWithLifecycle
:
class ComposeViewWithLifecycle @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
private val lifeCycle = LifecycleRegistry(this)
private val modelStore = ViewModelStore()
private val savedStateRegistryController = SavedStateRegistryController.create(this)
private var composeView: ComposeView? = null
private var content = @Composable {}
init {
ViewTreeLifecycleOwner.set(this, this)
ViewTreeViewModelStoreOwner.set(this, this)
ViewTreeSavedStateRegistryOwner.set(this, this)
compositionContext = createCompositionContext()
}
fun setContent(content: @Composable () -> Unit) {
this.content = content
composeView?.setContent(content)
}
override fun getLifecycle(): Lifecycle {
return lifeCycle
}
override fun getViewModelStore(): ViewModelStore {
return modelStore
}
override fun getSavedStateRegistry(): SavedStateRegistry {
return savedStateRegistryController.savedStateRegistry
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
savedStateRegistryController.performRestore(Bundle())
lifeCycle.currentState = Lifecycle.State.RESUMED
composeView = ComposeView(context)
composeView?.setContent(content)
addView(composeView, LayoutParams(
LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
lifeCycle.currentState = Lifecycle.State.DESTROYED
modelStore.clear()
removeAllViews()
composeView = null
}
// Exact copy of View.createCompositionContext() in androidx's WindowRecomposer.android.kt
private fun createCompositionContext(): CompositionContext {
val currentThreadContext = AndroidUiDispatcher.CurrentThread
val pausableClock = currentThreadContext[MonotonicFrameClock]?.let {
PausableMonotonicFrameClock(it).apply { pause() }
}
val contextWithClock = currentThreadContext + (pausableClock ?: EmptyCoroutineContext)
val recomposer = Recomposer(contextWithClock)
val runRecomposeScope = CoroutineScope(contextWithClock)
val viewTreeLifecycleOwner = checkNotNull(ViewTreeLifecycleOwner.get(this)) {
"ViewTreeLifecycleOwner not found from $this"
}
viewTreeLifecycleOwner.lifecycle.addObserver(
LifecycleEventObserver { _, event ->
@Suppress("NON_EXHAUSTIVE_WHEN")
when (event) {
Lifecycle.Event.ON_CREATE ->
// Undispatched launch since we've configured this scope
// to be on the UI thread
runRecomposeScope.launch(start = CoroutineStart.UNDISPATCHED) {
recomposer.runRecomposeAndApplyChanges()
}
Lifecycle.Event.ON_START -> pausableClock?.resume()
Lifecycle.Event.ON_STOP -> pausableClock?.pause()
Lifecycle.Event.ON_DESTROY -> {
recomposer.cancel()
}
}
}
)
return recomposer
}
// TODO: ComposeViewWithLifecycle should handle saving state and other lifecycle things
// override fun onSaveInstanceState(): Parcelable? {
// val superState = super.onSaveInstanceState()
// val bundle = Bundle()
// savedStateRegistryController.performSave(bundle)
// }
}