Sichtbare Extensions im MIT App Inventor geht nicht, sagt die AI2-Dokumentation. Geht dennoch, zumindest fast!
AI2 erlaubt es zwar nicht, sichtbare Extensions zu importieren. Aber einer nicht sichtbaren Extension ist es durchaus erlaubt, sichtbare Elemente zu erzeugen.
Dazu muss man wissen, dass sämtliche sichtbaren Elemente der Benutzeroberfläche auf der Android-View-Klasse beruhen. Davon gibt es verschiede Arten, die alle spezielle Eigenschaften und Funktionen haben, wie z.B. TextView zur Anzeige von Texten oder ImageView zur Anzeige von Bildern. Diese Unterschiede aber unwesentlich. Die Umgebung (das Betriebssystem) stellt nur ein Rechteck bereit, auf dem gezeichnet werden kann. Die Anzeige selbst wird von dem View beigesteuert. Dadurch ist es möglich, Views ohne Schaden auszutauschen.
So geht's: Im Designer wird eine sichtbare Komponente als Platzhalter zur Justierung der Ansicht verwendet. Dieser Platzhalter wird einer Instanz einer nicht sichtbaren Extension übergeben. Zur Laufzeit legt die Extension dann den gewünschten sichtbaren Android-View an und ersetzt den Platzhalter durch den neu angelegten View. Im Bespiel wird eine Komponente entwickelt, die eine analoge Uhr anzeigt.
In der Entwicklungsumgebung des App Inventors dient eine Button-Komponente als Platzhalter. Zur Laufzeit wird diese durch eine UrsAnalogClock-Komponente ersetzt. Größe, Position und Hintergrundfarbe wird vom Platzhalter übernommen.
Version | Anpassungen |
---|---|
1.0 (2020-12-13) | Initiale Version |
Inhaltsverzeichnis
Sichtbare Komponente (UrsClockComponent)
Extension-Klasse (UrsAnalogClock)
Das ZIP-Archiv UrsAI2AnalogClock zum Download. Das Archiv enthält den Quellcode, das kompilierte Binary zum Upload in den App Inventor und eine Beispiel-Anwendung.
AI2 unterscheidet zwischen sichtbaren und nicht sichtbaren Komponenten. Sichtbare Komponenten (visible component, z.B. eine Schaltfläche) sind Komponenten mit visueller Darstellung, nicht sichtbare Komponenten (z.B. ein AccelerometerSensor) stellen zusätzliche Funktionalitäten bereit.
Die sichtbaren Komponenten einer Ansicht (Screen, einer Android Activity entsprechend) sind hierarchisch angeordnet. Komponenten, die das Interface ComponentContainer implementieren, können Parent von weiteren Child-Komponenten sein. Wurzel der Hierarchie ist immer ein Screen-Objekt, eine Instanz einer von Form abgeleiteten Klasse. App Inventor stellt automatisch eine nicht löschbare Instanz der Klasse Screen1 als Start-Screen der App zur Verfügung.
Zur Strukturierung einer Screen-Komponente in einer AI2-App dienen die Komponenten aus der Rubrik "Layout", z.B. HorizontalArrangement. Diese Komponenten nehmen andere Komponenten auf, die wiederum selbst Layout-Komponenten sein können. Die Wurzel dieser Hierarchie ist eine Instanz der Klasse Screen. Jede andere Komponente ist der Screen-Komponente direkt oder indirekt untergeordnet. Nicht sichtbare Komponenten sind immer direkt der Screen-Komponente zugeordnet. |
In diese Struktur muss man die neue Komponente einbringen. Dazu muss zunächst einmal die Container-Komponente ermittelt werden, in die die Platzhalterkomponente eingebettet ist. Die folgende Grafik zeigt die Klassenhierarchie einer sichtbaren Komponente am Beispiel der Komponente Button.
Die Klasse ButtonBase ist ein Wrapper für die Klasse android.widget.Button, die wiederum von Klasse android.view.View, der Basisklasse für alle sichtbaren Steuerelemente, abgeleitet ist. Andere Komponenten binden andere Spezialisierungen der Klasse View ein. Die Klasse ButtonBase implementiert die Eigenschaften, Methoden und Ereignisse, die allen abgeleiteten Klassen, z.B. die diversen "Picker"-Komponenten, gemeinsam ist. Die Klasse Button definiert dann nur noch die Elemente, die speziell für eine Schaltfläche sind. Sämtliche sichtbaren Komponenten sind ggf. über mehrere Stufen von der Klasse AndroidViewComponent abgeleitet. AndroidViewComponent enthält ein geschütztes (protected) Feld container vom Typ ComponentContainer. Dieses Feld enthält den Verweis auf die übergeordnete Layout-Komponente. Der Konstruktor von AndroidViewComponent nimmt die Container-Komponente entgegen und legt sie im Feld container ab:
|
Die folgende Grafik zeigt eine typische Hierarchie der Container anhand der HorizontalArrangement-Komponente:
Die funktionale Klasse ist HVArrangement. Sie ist Basisklasse für HorizontalArrangement, HorizontalScrollArrangement, VerticalArrangement und VerticalScrollArrangement. Die abgeleiteten Klassen legen nur noch die Orientierung und die Fähigkeit zum Scrollen fest. HVArrangement ist von AndroidViewComponent abgeleitet und ist damit eine sichtbare Komponente. Weiterhin wird das Interface ComponentContainer implementiert. Somit kann diese Klasse als Parent für weitere Komponenten dienen. Zur Umsetzung dieses Interfaces nutzt HVArrangement eine Instanz der Klasse LinearLayout. Das LinearLayout-Element selbst nutzt ein android.widget.LinearLayout-Objekt zur Verwaltung der durch die Komponenten bereit gestellten Views. das android.widget.LinearLayout von Das android.widget.LinearLayout-Objekt wird über die Methode getLayoutManager() veröffentlicht. Anzumerken ist, dass letztendlich nur die in die Komponenten eingebetteten Views gespeichert und kontrolliert werden. Auf die Komponenten selbst ist über die Container kein Zugriff möglich. Die Klasse Form (Basisklasse für alle Screens) nutzt ebenfalls eine Instanz der Klasse LinearLayout zur Implementierung des ComponentContainer-Interfaces. Die Klasse TableArrangement nutzt ein TableLayout-Objekt zur Verwaltung der zugeordneten Komponenten. Die Klasse TableLayout nutzt jedoch ein android.widget.TableLayout-Objekt zur Verwaltung der zugeordneten Views. Auf dieses kann ebenfalls über die Methode getLayoutManager() zugegriffen werden. |
Den Vorgang des Einbindens der Komponenten in die Komponenten- und View-Hierarchie funktioniert etwa wie im folgenden Beispiel für eine Button-Komponente, die einer HorizontalArrangement-Komponente untergeordnet ist.
Dem Button-Objekt wird im Constructor der übergeordnete Container übergeben. Noch im Constructor ruft das Button-Objekt die Methode $add() des übergebenen Komponentencontainers auf und registriert sich damit beim Container. Der Container übergibt die Komponente an das eingebettete Layout-Element, hier vom Typ LinearLayout. Das LinearLayout-Element erfragt das der Komponente zu Grunde liegende android.view.View-Objekt und gibt es an das eingebettete android.widget.LinearLayout weiter. Ähnlich funktioniert es bei dem TableLayout, das vom TableArrangement benutzt wird. Hier wird jedoch die zugeordnete Komponente nicht sofort sondern verzögert über einen Aufruf von android.os.Handler.post() in das integrierte android.widget.TableLayout-Objekt eingefügt. Damit erfolgt die Einbettung über eine der ersten Nachrichten in der Message-Queue, d.h. nachdem alle anderen Initialisierungsaufgaben beendet wurden. Der Grund hierfür ist, dass erst alle zugeordneten Komponenten aufbereitet sein müssen, bevor die Tabelle layoutet werden kann. |
Mit diesen Informationen kann man nun an das Projekt heran gehen. Es besitzt folgende Struktur:
Die abstrakte Klasse UrsViewBase ist die Basisklasse für eine nicht sichtbare Komponente. Die stellt die Funktionen für den Austausch des Views bereit. Die Designer-Eigenschaft PlaceHolder dient zur Festlegung der Platzhalter-Komponente.
Der zugehörige Auswahldialog listet alle sichtbaren Komponenten auf. Zulässig sind aber solche vom Typ Button. Die Auswahl einer Komponente von einem anderen Typ führt zu einem Laufzeitfehler. UrsViewBase spezifiziert keinen spezielle Ansicht (View). Dies muss eine abgeleitete Klasse (hier UrsAnalogClock) über die Methode createViewComponent erledigen. Sie erstellt eine Instanz der Anzeige-Klasse (hier InternalClockView) und gibt sie zurück. InternalClockView nutzt eine interne Instanz der android/widget/AnalogClock-Klasse, die die Anzeige der Uhr übernimmt.. Die Anzeige-Klasse InternalClockView ist wie eine sichtbare Komponente konstruiert. Der Unterschiede sind:
|
Die Komponente UrsClockComponent ersetzt zur Laufzeit die Platzhalter-Komponente. Sie ist recht einfach aufgebaut, da das zu Grunde liegende android/widget/AnalogClock-Element nur wenige Einstellungsmöglichkeiten erlaubt. Als einzige Eigenschaft ist die Hintergrundfarbe implementiert.
package de.ullisroboterseite.ursanalogclock;
import com.google.appinventor.components.runtime.*;
import android.view.View;
import android.widget.AnalogClock;
public class UrsClockComponent extends AndroidViewComponent {
final AnalogClock view;
UrsClockComponent(ComponentContainer container) {
super(container);
view = new AnalogClock(container.$context());
}
@Override
public View getView() {
return view;
}
public void setBackgroundColor(int argb) {
view.setBackgroundColor(argb);
}
}
Die Klasse ist von AndroidViewComponent abgeleitet, damit sie
auf den Screen platziert werden kann. Das Feld container in
AndroidViewComponent ist als final
deklariert, kann also nicht nachträglich geändert werden und muss deshalb im Constructor übergeben
werden (super(container);
). D.h. die Komponente kann erst dann erstellt
werden, wenn klar ist, was der zukünftige Container sein wird.
Die nicht sichtbare Komponente UrsAnalogClock ist eine normale AI2-Extension-Klasse. Sie ist von der Klasse UrsViewBase abgeleitet, die die Ersetzung des Platzhalters übernimmt. Sie Verwaltet eine Instanz der Klasse UrsClockComponent, die den Platzhalter austauschen soll.
@DesignerComponent(version = 1, //
versionName = "1.0", //
dateBuilt = "2020-12-12", //
description = "AI2 extension Analog clock component.", //
category = com.google.appinventor.components.common.ComponentCategory.EXTENSION, //
nonVisible = true, //
helpUrl = "http://UllisRoboterSeite.de/android-AI2-visible.html", //
iconName = "aiwebres/icon.png")
@SimpleObject(external = true)
public class UrsAnalogClock extends UrsViewBase {
UrsClockComponent ursClockComponent = null;
/**
* Initialisiert eine neue Instanz der UrsAnalogClock-Klasse
* @param container Die Übergeordnete Komponente.
* @note Da es sich um eine nicht sichtbare Komponente handelt, ist der container der übergeordnete Screen.
*/
public UrsAnalogClock(ComponentContainer container) {
super(container);
}
...
Die Klasse muss die Methode createViewComponent überschreiben, in der die neue Instanz von sichtbaren Komponente angelegt wird.
/**
* Erstellt die sichtbare Komponente mit einer AnalogClock-View.
* @param visibleContainer Der Container, in die die Komponente eingebunden werden soll.
* @param placeholder Die Komponente, die ersetzt werden soll (z.B. zur Übernahme von Eigenschaften)
* @return Eine Instanz der sichtbaren Komponente.
* @note Es werden Width, Height und BackgroundColor vom Platzhalter übernommen.
*/
AndroidViewComponent createViewComponent(ComponentContainer visibleContainer, Button placeholder) {
ursClockComponent = new UrsClockComponent(visibleContainer);
return ursClockComponent;
}
Zur Konfiguration der neuen Komponente dient die Methode replacementDone. Sie wird aufgerufen, wenn der Platzhalter im Container ersetzt wurde. Insbesondere die Eigenschaften Width und Height werden über den Container eingestellt. Sie können deshalb erst dann festgelegt werden, wenn die neue Komponente in die Child-Liste des Containers eingetragen ist. Im Beispiel sollen die Eigenschaften Width, Height und BackgroundColor vom Platzhalter übernommen werden. Dabei gibt es noch ein weiteres Problem. Das Ereignis Screen.Initialize wird ausgeführt, bevor die neue Komponente erstellt und der Platzhalter ersetzt wurde. Dies lässt sich auf Grund des besonderen Verhaltens der TableArrangement-Komponente nicht vermeiden (s. unten).
Hierfür gibt es zwei Lösungen. Die erste ist man verzichtet auf die Möglichkeit die Komponente im Ereignis Screen.Initialize und nutzt stattdessen das Ereignis ComponentReplaced der Komponente. Die zweite Möglichkeit ist, sich zu merken, ob der Wert einer Eigenschaft gesetzt wurde und dann auf die Übernahme desselben aus dem Platzhalter zu verzichten. Die generische Klasse PropertyBackup unterstützt dabei.
package de.ullisroboterseite.ursanalogclock;
/**
* Speichert einen Wert.
* Es wird nachgehalten, ob ein gültiger Wert vorliegt.
*/
public class PropertyBackup<E extends Object> {
E value = null;
boolean isValid = false;
/**
* Initialisiert eine neue Instanz der PropertyBackup-Klasse mit dem angegebeben Wert.
* @param value
*/
public PropertyBackup(E value) {
this.value = value;
}
/**
* Legt den Wert der Instanz fest.
* @param value Der festzulegende Wert.
*/
public void setValue(E value) {
this.value = value;
isValid = true;
}
/**
* Ruft den aktuell gepeicherten Wert ab.
* @return Der aktuell gepeicherte Wert
*/
public E getValue() {
return value;
}
/**
* Ruft, wenn gültig, den gespeicherten Wert ab, ansonsten den übergebenen Vorschlagswert.
* Wenn kein gültiger Wert vorliegt, wird der Vorschalgswert übernommen.
* @param defaultValue Der Vorschalgswert.
* @return Der gespeicherte Wert oder der übergebene Vorschlagswert
*/
public E getAndReplaceValue(E defaultValue) {
if (isValid)
return value;
else {
setValue(defaultValue);
return defaultValue;
}
}
}
Die Klasse merkt sich (isValid), ob bereits ein Wert festgelegt wurde. Die Methode getAndReplaceValue(E defaultValue) liefert dann den bereits gespeicherten Wert, ansonsten wird defaultValue zurück gegeben.
Am Bespiel der Eigenschaft Width sieht dies wie folgt aus:
PropertyBackup<Integer> width = new PropertyBackup<Integer>(80);
/**
* Returns the horizontal width of the component, measured in pixels.
* @return width in pixels
*/
@SimpleProperty(category = PropertyCategory.APPEARANCE,
description = "Returns the component's horizontal width, measured in pixels")
public int Width() {
return width.getValue();
}
/**
* Specifies the horizontal width of the component, measured in pixels.
* @param value in pixels
*/
@SimpleProperty(description = "Specifies the horizontal width of the component, measured in pixels.")
public void Width(int value) {
width.setValue(value);
if (ursClockComponent != null) // Zur Zeit von Screen.Initialize ist die Komponente noch nicht angelegt.
ursClockComponent.Width(value);
}
Die Festlegung der Eigenschaften der neu angelegten Komponente geschieht dann in der Methode replacementDone.
/**
* Wird ausgelöst, wenn die Ersetzung stattgefunden hat.
* @param container Der Container der neu erstellten Komponente
* @param placeholder Der Platzhalter
* @note Width und Height werden über den Container eingestellt und
* kann deshalb erst nach dem Ersatz festgelegt werden.
*/
@Override
void replacementDone(ComponentContainer container, Button placeholder) {
ursClockComponent.Width(width.getAndReplaceValue(placeholder.Width()));
...
}
Die Klasse UrsViewBase ist die Basis-Klasse für die nicht sichtbare Extension. Sie übernimmt den Austausch des Platzhalters gegen die gewünschte Komponente.
Der Austausch wird vom Ereignis onInitialize ausgelöst. Dies wird an die Komponenten weiter geleitetet, nachdem Screen.Initialize ausgeführt wurde.
/**
* Wird vom übergeordneten Form aufgerufen nachdem das Ereignis Screen.Initialize ausgelöst wurde.
*/
@Override
public void onInitialize() {
// Wenn im Companion eine neue Komponente eingefügt wird,
// ist die PlaceHolder-Komponente noch festgelegt.
if (placeholder == null)
return;
// Für den Paltzhalter sind nur Komponenten vom Typ Button erlaubt.
if (!(placeholder instanceof Button)) {
Log.d(LOG_TAG, "Invalid Placeholder component type");
Notifier.oneButtonAlert(thisActivity, "App will be canceled",
"Error on Placeholder property: Invalid component type", "OK", new Runnable() {
public void run() {
Form.finishApplication();
}
});
return;
}
// replacePlaceholder nicht direkt sondern verzögert ausführen.
// Die TableArrangements sind noch nicht korrekt initialisiert.
handler.post(new Runnable() {
public void run() {
replacePlaceholder();
}
});
}
Zunächst wird auf eine besondere Eigenschaft bei der Ausführung der App im Companion eingegangen. Wird dort eine neue Instanz der Komponente in den Screen eingefügt wird, ist die Eigenschaft Placeholder noch mit null belegt. Der Refresh des Screens erfolgt dann mit einem null-Wert.
Als nächstes wird überprüft, ob die Platzhalter-Komponente vom Typ Button ist. Wenn nicht, wird die App mit einer Fehlermeldung abgebrochen. Die Erweiterung auf weitere Komponententypen wäre möglich, ist aber aufwändig und bringt keine wesentlichen Vorteile.
Zum Schluss wird der Aufruf von replacePlaceholder in die Message-Queue gestellt. Dies ist notwendig, weil zu diesem Zeitpunkt die TableArrangement-Komponenten noch nicht korrekt initialisiert sind.
Das Austauschen der Komponenten erfolgt in der Methode replacePlaceholder. Die Klasse Helper kapselt den Zugriff auf die nicht öffentlichen der Felder per Reflection. Falls wieder erwarten auf diese Felder nicht zugegriffen werden kann, bricht die App mit einer Fehlermeldung ab.
/**
* Ersetzt die Platzhalter-Komponente im Container.
*/
void replacePlaceholder() {
try {
placeholderParent = Helper.getControlsParent(placeholder);
newViewComponent = createViewComponent(placeholderParent, (Button) placeholder);
layoutManager = Helper.getContainersViewGroup(placeholder);
if (layoutManager instanceof android.widget.TableLayout) {
int row = placeholder.Row();
int col = placeholder.Column();
TableRow tableRow = (TableRow) layoutManager.getChildAt(row);
tableRow.removeViewAt(col);
View cellView = newViewComponent.getView();
tableRow.addView(cellView, col, placeholder.getView().getLayoutParams());
} else {
int ind = layoutManager.indexOfChild(placeholder.getView());
layoutManager.removeView(placeholder.getView());
layoutManager.addView(newViewComponent.getView(), ind, new android.widget.LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, 0f));
}
placeholder.Visible(false); // Die Ansicht des Platzhalters wird nicht mehr benötigt.
replacementDone(placeholderParent, (Button) placeholder);
ComponentReplaced();
} catch (Exception e) {
Notifier.oneButtonAlert(thisActivity, "App will be canceled", "Cannot replace component",
"OK", new Runnable() {
public void run() {
Form.finishApplication();
}
});
return;
}
}
Beim Ersatz der Komponenten muss auf die Container-Art Rücksicht genommen werden. TableLayout und LinearLayout müssen unterschiedlich bedient werden. Nach dem Austausch wird der Platzhalter nicht mehr benötigt und wird unsichtbar geschaltet. replacementDone erlaubt es der neuen Komponente den neu erstellten View zu konfigurieren. Zum Schluss wird das Ereignis ComponentReplaced ausgelöst, das der Applikation mitteilt, dass der Austauscht komplettiert ist.
getControlsParent ermöglicht den Zugriff auf die übergeordnete Container-Komponente des Platzhalters. Das zugehörige Feld container ist als protected deklariert und deshalb nicht von außen zugreifbar. Hier muss Reflection weiter helfen:
/**
* Ermittelt die übergeordnete Komponente eines Steuerelements
* @param viewComponent Komponente, deren Container-Komponente ermittelt werden soll.
* @return Die übergeordnete Komponente. Ist entweder vom Typ Screen, ReplForm, ...Arrangement oder TableArrangement
* @throws Exception Der Zugriff auf das Feld 'container' war nicht möglich.
*/
ComponentContainer getControlsParent(AndroidViewComponent viewComponent) throws Exception {
// Die übergebene Komponente ist ggf. indirekt von AndroidViewComponent abgeleitet.
// Deshalb solange in der Klassenhierarchie nach oben steigen, bis man bei AndroidViewComponent angekommen ist.
Class placeholderClass = viewComponent.getClass();
while (placeholderClass != AndroidViewComponent.class)
placeholderClass = placeholderClass.getSuperclass();
Field f = placeholderClass.getDeclaredField("container");
f.setAccessible(true); // ermöglicht den Zugriff auf private Variablen
return (ComponentContainer) f.get(viewComponent); // ist entweder Screen, ReplForm, ...Arrangement oder TableArrangement
}
getContainersViewGroup ermittelt die android.view.ViewGroup-Instanzen die letztendlich die Views der Komponenten verwaltet.
/**
* Ermittelt die integrierte ViewGroup des dem Platzhalter übergeordneten Containers
* @param placeholder Der zu Grunde liegende Platzhalter
* @return True, wenn es sich bei dem Container um ein TableArrangement handelt.
* @throws Exception Die internen Felder sind nich zugreifbar
*/
static public android.view.ViewGroup getContainersViewGroup(AndroidViewComponent placeholder)
throws Exception {
ComponentContainer placeholderParent = Helper.getControlsParent(placeholder);
Class placeholderParentClass = placeholderParent.getClass();
// bei ...Arrangement ist eine Zwischenableitung HVArrangement vorhanden
// bei der Form liegt die abgeleitete Klasse ScreenX vor
if (placeholderParent instanceof HVArrangement || placeholderParent instanceof Form) {
placeholderParentClass = placeholderParentClass.getSuperclass();
}
// Im Companion ist der Typ ScreenX vom Typ ReplForm abgeleitet, der nochmals von Form abgeleitet ist
if (placeholderParent instanceof ReplForm) {
placeholderParentClass = placeholderParentClass.getSuperclass();
}
Field f = placeholderParentClass.getDeclaredField("viewLayout");
f.setAccessible(true);
Object viewLayout = f.get(placeholderParent);
android.view.ViewGroup layoutManager;
// viewLayout kann vom Typ LinearLayout oder TableLayout sein
// beide besitzen die Methode "getLayoutManager"
try {
layoutManager = ((LinearLayout) viewLayout).getLayoutManager();
// ist vom Typ "LinearLayout$1", eine Ableitung von android.view.ViewGroup
} catch (Exception e) {
try {
layoutManager = ((TableLayout) viewLayout).getLayoutManager();
} catch (Exception ex) {
throw ex;
}
// nichts zu tun
}
return layoutManager;
}
Die folgende Grafik zeigt die AI2-Designer-Ansicht.
Die Platzhalter Eigenschaft ist mit dem Namen der zu ersetzenden Komponente (Button1) belegt.
Bei der Verwendung der Extension ist zu beachten, das die Eigenschaften zum Zeitpunkt von Screen.Initialize noch nicht zur Verfügung stehen! Sie stehen erst dann bereit, wenn auch das Ereignis ComponentReplaced ausgelöst wurde. In der Beispielanwendung wird die Höhe der Komponente auf dessen Breite eingestellt, damit sich ein quadratisches Bild ergibt. Da die Breite auf FillParent eingestellt ist, kann die Höhe nicht zur Design-Zeit, sondern erst zur Laufzeit festgelegt werden:
Speziell bei UrsAnalogClock-Extension wäre es prinzipiell möglich, dies auch im Ereignis Screen.Initialize zu erledigen. Es muss dann allerdings auf die Eigenschaften der Platzhalter-Komponente zugegriffen werden.
Für die Erstellung eigener Extensions habe ich einige Tipps zusammengestellt: AI2 FAQ: Extensions entwickeln.