EdgeBrowser - Ein Slider Control

Access kennt keinen Schieberegler - der acEdgeBrowser schon. Dieser Artikel zeigt, wie ein HTML-Slider im Browser-Steuerelement aufgebaut wird, wie der Wert zurück nach VBA gelangt und warum der richtige Zeitpunkt dabei wichtiger ist als die Geschwindigkeit.

Zwei Welten, eine Brücke

Access hat keinen Schieberegler. Der acEdgeBrowser dagegen zeigt jedes Control an, das ein moderner Browser kennt - also auch ein <input type="range">. Die Anzeige ist der einfache Teil. Interessant wird die Frage, wie der Wert aus der Seite zurück nach VBA kommt.

Denn hier treffen zwei getrennte Welten aufeinander:

Access / VBA WebView2 / HTML + JavaScript
Sprache VBA JavaScript
Ausführung ein einziger UI-Thread eigener Prozess, eigener Speicher
Arbeitsweise Schritt für Schritt, wartet auf jede Antwort ereignisgesteuert, wartet auf niemanden
Takt so schnell wie der Code 60 Bilder pro Sekunde, ein Frame = 16,7 ms

Beide Seiten teilen sich weder Speicher noch Variablen, und sie können sich nicht gegenseitig aufrufen. Was fehlt, ist eine Brücke.

Was der acEdgeBrowser bereitstellt

Das Steuerelement ist nicht die vollständige WebView2-API. Verfügbar ist:

Aufgabe Im acEdgeBrowser
JavaScript ausführen ExecuteJavascript - ohne Rückgabewert, günstig
Wert aus der Seite lesen RetrieveJavascriptValue - synchron, teuer
Seite ruft VBA auf gibt es nicht - kein Web-Message-Event
Navigation Navigate "https://msaccess/" & <Pfad>
Rückmeldung nach dem Laden DocumentComplete - feuert zweimal pro Navigation
Maus-Ereignisse Click, MouseUp(Button, Shift, X, Y)

Die dritte Zeile prägt die gesamte Architektur: Die Seite kann nicht anklopfen. Access muss nachsehen - und entscheidet damit selbst, wann Verkehr über die Brücke läuft.

Der Aufbau: drei Module

Modul Aufgabe
clsWebBridge Der generische Transport. Diese Klasse ist für jedes webControl identisch - Slider, ComboBox, TreeView. Sie kennt keinen Slider.
clsSlider Die Fachlogik. Minimum, Maximum, Vorgabewert, Farben; macht aus rohem Browser-Verkehr ein einziges ValueChanged-Ereignis.
modSliderHtml Die Seite selbst, als VBA-String im Projekt. Kein externes HTML-File, das verloren gehen oder nicht zum Code passen kann.
modWebBridge Zustandslose Helfer: zentrale Fehlermeldung, JSON-Parser, UTF-8-Datei schreiben.

Dazu kommt das Hostformular mit einem acEdgeBrowser-Steuerelement und einem Textfeld für die Ausgabe.

Abbildung 1: Testformular.

Die Seite: HTML, das VBA erzeugt

modSliderHtml liefert die komplette Seite - CSS, Body und JavaScript - als String. Weil die Seite im Projekt liegt, kann sie nicht von der Codebasis abweichen.

Marker statt Platzhalter

In der Seite stehen an den variablen Stellen Marker, die für sich genommen gültige JavaScript-Kommentare sind. clsSlider ersetzt sie vor dem Laden:

    sHtml = Slider_EmbeddedHtml()
    sHtml = Replace(sHtml, "/*__STYLE__*/{}", StyleJson())
    sHtml = Replace(sHtml, "/*__MIN__*/0", CStr(m_lngMin))
    sHtml = Replace(sHtml, "/*__MAX__*/100", CStr(m_lngMax))
    sHtml = Replace(sHtml, "/*__DEFAULT__*/50", CStr(m_lngDefault))
    sHtml = Replace(sHtml, "/*__SHOWFIELDS__*/false", IIf(m_bolShowFields, "true", "false"))

Das hat zwei Vorteile. Erstens stimmt bereits das erste gezeichnete Bild - Bereich, Farben und Feldsichtbarkeit stehen im HTML, es muss nichts nachgereicht werden. Zweitens lässt sich die Seite unverändert im normalen Browser öffnen: Ohne Ersetzung sind die Marker schlicht Kommentare, und die Vorgabewerte greifen.

Vor Init setzen ist günstiger: ShowFields und die vier Farbeigenschaften werden vor dem Aufruf von Init in das HTML eingebacken. Wer sie danach setzt, erreicht dasselbe - dann aber über einen Aufruf in die geladene Seite hinein.

Der Briefkasten in der Seite

Weil die Seite VBA nicht rufen kann, legt sie ihre Nachrichten in einen Briefkasten. Access holt sie dort ab, wenn es passt. Der Briefkasten ist ein kleines JavaScript-Objekt, das modSliderHtml mit ausliefert:

    s = s & "    var WebBridge={queue:[]," & vbLf
    s = s & "      send:function(a,d){var o={action:a};if(d){for(var k in d)o[k]=d[k];}" & _
            "for(var i=0;i<this.queue.length;i++){if(this.queue[i].action===a){this.queue[i]=o;return;}}" & _
            "this.queue.push(o);}," & vbLf
    s = s & "      collect:function(){if(!this.queue.length)return '';var r=JSON.stringify(this.queue);this.queue=[];return r;}};" & vbLf

send() wirft einen Brief ein, collect() leert den Kasten in einem Zug und gibt den Inhalt als JSON zurück. Der entscheidende Kniff steckt in send(): Ein Brief ersetzt einen bereits wartenden Brief derselben Art, statt sich dahinter anzustellen. Der Kasten enthält damit immer genau einen Brief, und der trägt immer den aktuellen Wert.

Der Weg des Wertes

Was die Seite tut

Beim Ziehen feuert das input-Ereignis einmal pro Mausbewegung. Die Seite tut dann zweierlei - beides bleibt vollständig innerhalb des Browsers:

    s = s & "    slider.addEventListener('input',function(){var v=parseInt(this.value,10);" & _
            "showValue(v);WebBridge.send('change',{value:v});});" & vbLf

showValue() merkt sich die Zahl nur und lässt sie im nächsten Animationsframe zeichnen - so wird die Anzeige höchstens einmal pro Bild aktualisiert, statt mehrmals pro Bild vergeblich. send() frischt den wartenden Brief auf.

Warum bei jeder Bewegung gesendet wird: Weil send() ersetzt statt anhängt, kostet das nichts - es bleibt bei einem einzigen Eintrag. Der Nutzen ist, dass der Brief schon bereitliegt, bevor der Anwender loslässt. Access findet also unabhängig vom Zeitpunkt immer den aktuellen Wert vor.

Beim Loslassen feuert zusätzlich change. Dort wird der Wert endgültig übernommen und in das Feld vba_output_value geschrieben.

Wann VBA nachsieht

RetrieveJavascriptValue ist synchron: Der Aufruf hält den Access-UI-Thread an und zwingt den Renderer, mitten im Zeichnen eines Frames stehenzubleiben. Er ist nicht langsam, aber er ist teuer - und deshalb entscheidet die Brücke sehr bewusst, wann er stattfindet.

Zwei Windows-API-Funktionen liefern die nötige Information:

#If VBA7 Then
    Private Declare PtrSafe Function GetLastInputInfo Lib "user32" (ByRef plii As LASTINPUTINFO) As Long
    Private Declare PtrSafe Function GetTickCount Lib "kernel32" () As Long
    Private Declare PtrSafe Function GetAsyncKeyState Lib "user32" (ByVal vKey As Long) As Integer
#Else
    Private Declare Function GetLastInputInfo Lib "user32" (ByRef plii As LASTINPUTINFO) As Long
    Private Declare Function GetTickCount Lib "kernel32" () As Long
    Private Declare Function GetAsyncKeyState Lib "user32" (ByVal vKey As Long) As Integer
#End If

GetAsyncKeyState beantwortet die Frage “ist die linke Maustaste gerade gedrückt?” - und damit “wird gerade gezogen?”:

Private Function MouseIsDown() As Boolean
On Error Resume Next
    MouseIsDown = (GetAsyncKeyState(C_VK_LBUTTON) < 0)
End Function

Weil das bei jedem Timer-Schlag geprüft wird, kennt die Brücke auch die Flanke von gedrückt nach losgelassen. Diese Flanke ist das Ende des Ziehens. Der Kern von HandleTimer besteht daher aus vier Fällen:

    ' ---- (1) Die Seite ist noch nicht da: dafuer sorgen, dass sie es wird ----
    If Not m_bolReady Then
        CheckLoadStalled
        GoTo END_PROCEDURE
    End If

    ' ---- (2) Taste gedrueckt: ein Drag wird niemals unterbrochen ----
    bolDown = MouseIsDown()
    If bolDown Then
        m_bolMouseWasDown = True
        GoTo END_PROCEDURE
    End If

    ' ---- (3) Taste gerade losgelassen: Interaktion vorbei, Wert SOFORT holen ----
    If m_bolMouseWasDown Then
        m_bolMouseWasDown = False
        ReadQueue
        GoTo END_PROCEDURE
    End If

    ' ---- (4) Tastatur und Rest: erst nach einer Eingabepause lesen ----
    If TickDelta(m_lngLastRead, GetTickCount()) < m_lngMaxWaitMs Then
        If IdleMs() < m_lngGateMs Then GoTo END_PROCEDURE
    End If

    ReadQueue

Fall 2 sorgt dafür, dass während des Ziehens kein einziger Lesevorgang stattfindet. Fall 3 holt den Wert im ersten Schlag nach dem Loslassen. Fall 4 fängt alles ab, was keine Maus ankündigt - vor allem die Bedienung per Tastatur: gelesen wird, sobald der Anwender 150 ms still war, spätestens aber nach 3 Sekunden.

Weil ein Schlag damit fast nichts kostet, darf der Timer schnell laufen: 60 ms. Das ist zugleich die maximale Verzögerung, mit der ein Wert im Formular erscheint.

Liefert das Steuerelement MouseUp oder Click, kann das Hostformular zusätzlich FlushNow aufrufen und den Wert noch einen Schlag früher holen. Notwendig ist das nicht - die Brücke erkennt das Ende des Ziehens ohnehin selbst.

Was aus dem Brief wird

ReadQueue ist die einzige Stelle, die tatsächlich in die Seite hineingreift. Ein Aufruf leert den gesamten Briefkasten, egal ob ein oder zwanzig Einträge darin liegen:

    sRaw = Nz(m_objWebCtrl.RetrieveJavascriptValue("WebBridge.collect()"), "")
    If Len(sRaw) = 0 Then GoTo END_PROCEDURE

    sJson = UnwrapJsString(sRaw)
    If Len(sJson) = 0 Or sJson = "[]" Or sJson = "null" Then GoTo END_PROCEDURE

    Set colCmd = SplitJsonArray(sJson)
    For Each vItem In colCmd
        sCommand = CStr(vItem)
        sAction = ExtractJSONValue(sCommand, "action")
        If Len(sAction) > 0 Then
            RaiseEvent CommandReceived(sAction, sCommand)
        End If
    Next vItem

clsSlider nimmt dieses Ereignis entgegen, prüft den Inhalt und reicht ihn als typisiertes Ereignis an das Formular weiter:

    If sAction = "change" Then
        sValue = ExtractJSONValue(sPayload, "value")
        If IsNumeric(sValue) Then
            RaiseEvent ValueChanged(ClampLong(CLng(sValue), m_lngMin, m_lngMax))
        End If
    End If

Alles, was über die Brücke kommt, ist Text. Deshalb wird geprüft, ob wirklich eine Zahl vorliegt, und der Wert anschließend in den gültigen Bereich geklemmt.

Der Lebenszyklus

Öffnen

Form_Load bereitet nur vor und startet einen Einmal-Schuss. Geladen wird die Seite erst aus dem ersten Timer-Schlag heraus, wenn das Formular vollständig aufgebaut ist:

    WireEvents
    m_bolStarted = False
    Me.TimerInterval = C_PACE_BOOT      ' 200 ms
    If Not m_bolStarted Then
        m_bolStarted = True
        Me.TimerInterval = C_PACE       ' 60 ms
        StartSlider
        GoTo END_PROCEDURE
    End If

StartSlider erzeugt clsSlider, setzt die Eigenschaften und ruft Init. Die Brücke schreibt die fertige Seite als UTF-8-Datei nach %TEMP% und navigiert dorthin. Jeder Ladevorgang bekommt dabei eine eigene Adresse, ältere Dateien desselben Controls werden vorher entfernt:

    Kill Environ$("TEMP") & "\ws_" & m_sControlName & "_*.html"

    m_sTempFile = NextTempHtmlPath(m_sControlName, CStr(GetTickCount()) & "_" & m_lngLoadTries)
    If Not WriteUtf8File(m_sTempFile, m_sHtml) Then
        ErrBox 9102, "Could not write temp HTML file.", C_MODULE, C_PROC, "Path: " & m_sTempFile
        GoTo END_PROCEDURE
    End If

    m_bolReady = False
    m_bolDocExpected = True
    m_lngNavStart = GetTickCount()
    m_objWebCtrl.Navigate "https://msaccess/" & m_sTempFile

Mit m_lngNavStart beginnt die Überwachung des Ladevorgangs. Meldet die Seite innerhalb von 1,5 Sekunden kein DocumentComplete, wird die Navigation wiederholt - bis zu dreimal, danach folgt eine eindeutige Meldung:

    If Not m_bolDocExpected Then GoTo END_PROCEDURE
    If TickDelta(m_lngNavStart, GetTickCount()) < C_LOAD_WAIT_MS Then GoTo END_PROCEDURE

    If m_lngLoadTries >= C_LOAD_MAXTRY Then
        m_bolDocExpected = False
        ErrBox 9103, "The page never reported DocumentComplete - the browser control stayed " & _
               "empty.", C_MODULE, C_PROC, "Control: [" & m_sControlName & "]"
        GoTo END_PROCEDURE
    End If

    m_lngLoadTries = m_lngLoadTries + 1
    NavigateNow

Trifft DocumentComplete ein, wird nur die erste Meldung ausgewertet - das Ereignis feuert zweimal pro Navigation. Ab diesem Moment ist die Brücke bereit.

Schließen

Beim Schließen zählt die Reihenfolge. Form_Unload stoppt als Erstes den Timer, danach gibt Destroy die Brücke frei - Flags zuerst, Referenz zuletzt:

On Error Resume Next

    m_bolReady = False
    m_bolDocExpected = False
    m_bolBusy = False
    m_bolMouseWasDown = False
    Set m_objWebCtrl = Nothing

    If Len(m_sTempFile) > 0 Then Kill m_sTempFile

Ab der ersten Zeile spricht die Klasse das Steuerelement nicht mehr an - weder lesend noch schreibend, und auch die Ladeüberwachung ruht. Der zuletzt eingestellte Wert ist zu diesem Zeitpunkt längst abgeholt.

Kosten der einzelnen Schritte

Schritt Größenordnung Bewertung
HTML im Speicher zusammenbauen ca. 1 ms vernachlässigbar
Temp-Datei schreiben (ADODB.Stream) 2 - 5 ms vernachlässigbar
ExecuteJavascript (Wert hineinschieben) ca. 1 ms niemand wartet auf Antwort
RetrieveJavascriptValue (ein Lesevorgang) 1 - 10 ms hält beide Seiten an
Seite laden, parsen, rendern 150 - 400 ms einmalig beim Öffnen

Zur Einordnung: Das sind Größenordnungen, keine Messreihe - sie hängen von Maschine, WebView2-Version und Seitengröße ab. Das Verhältnis zwischen den Zeilen ist die Aussage, nicht die absolute Zahl.

Der Seitenaufbau dominiert alles andere, lässt sich aber nicht beeinflussen: Dort baut eine vollständige Browser-Engine in einem eigenen Prozess ein Dokument auf. Genau deshalb wird dieser Preis einmalig beim Laden bezahlt und alles Weitere über die günstige Richtung abgewickelt. Der Lesevorgang dagegen ist klein - und er ist der einzige Posten, dessen Zeitpunkt frei wählbar ist.

Eigenschaften, Methoden, Ereignis

clsSlider ist die Schnittstelle für das Hostformular:

Element Bedeutung
Init web, sName, lngMin, lngMax, lngDefault Bindet das Steuerelement und lädt die Seite. Einmal aufrufen.
HandleTimer Aus Form_Timer aufrufen. Die Brücke entscheidet selbst, ob der Moment passt.
HandleNavigationComplete Aus DocumentComplete aufrufen.
FlushNow [bolForce] Optional aus MouseUp / Click aufrufen - holt den Wert ohne Wartezeit.
Reset Setzt auf den Vorgabewert zurück, live und ohne Neuladen.
ApplyRange lngMin, lngMax, lngDefault Ändert den Bereich zur Laufzeit, live und ohne Neuladen.
Destroy Aus Form_Close aufrufen.
ShowFields Blendet die vba_*-Felder und die Linie darunter ein oder aus.
BackColor, ForeColor, AccentColor, ObjectColor Farben als #rrggbb. Aus AccentColor werden die hellere Zieh-Variante und der Leuchtring automatisch abgeleitet.
DefaultValue Liest den aktuellen Vorgabewert.
ValueChanged(lngValue) Das eine Ereignis, auf das das Formular hört.

Zusätzlich lässt sich das Leseverhalten der Brücke justieren: PollGateMs ist die geforderte Eingabepause (Vorgabe 150 ms), PollMaxWaitMs die Obergrenze, nach der auch ohne Pause gelesen wird (Vorgabe 3000 ms).

Konventionen und Grenzen

Punkt Was zu beachten ist
Ereigniseigenschaften Ein .cls-Import setzt sie nicht. Me.OnTimer = "[Event Procedure]" gehört in den Code, sonst feuert Form_Timer nie - fehlerfrei und stumm.
DocumentComplete Feuert zweimal pro Navigation. Nur der erste Aufruf zählt.
Farben Nur #rrggbb wird akzeptiert. Die Prüfung ist zugleich der Schutz gegen eingeschleustes CSS oder JavaScript.
Temp-Dateien Liegen in %TEMP%, ein Satz je Control-Name. Mehrere webControls auf einem Formular kommen sich nicht ins Gehege.
Drag & Drop Natives HTML5-Drag arbeitet im gehosteten Browser nicht zuverlässig. Für eigene Controls: Maus-Ereignisse verwenden.
Late Binding Durchgehend, inklusive ADODB.Stream. Keine Verweise nötig, 32 und 64 Bit laufen unverändert.

Reihenfolge beim Schließen einhalten. Wer ein eigenes Hostformular baut: erst den Timer stoppen, dann Destroy aufrufen - und aus einem Steuerelement, das gerade abgebaut wird, nichts mehr lesen. Ein synchroner Zugriff in dieser Phase kann den WebView2-Host so hinterlassen, dass die nächste Instanz nicht mehr startet.

Einbindung

Vier Module importieren: modWebBridge, clsWebBridge, clsSlider, modSliderHtml. Auf das Formular kommt ein acEdgeBrowser-Steuerelement, hier webSlider, dazu ein Textfeld für die Ausgabe.

Der Rest ist überschaubar:

Private WithEvents m_objSlider As clsSlider
Private m_bolStarted           As Boolean


Private Sub Form_Load()
    Me.OnTimer = "[Event Procedure]"
    Me.TimerInterval = 200              ' Einmal-Schuss: laedt gleich die Seite
End Sub


Private Sub Form_Timer()
    If Not m_bolStarted Then
        m_bolStarted = True
        Me.TimerInterval = 60
        Set m_objSlider = New clsSlider
        m_objSlider.ShowFields = True
        m_objSlider.AccentColor = "#cc0000"
        m_objSlider.Init Me.webSlider, "webSlider", -50, 50, 0
        Exit Sub
    End If
    If Not m_objSlider Is Nothing Then m_objSlider.HandleTimer
End Sub


Private Sub webSlider_DocumentComplete(URL As Variant)
On Error Resume Next
    If Not m_objSlider Is Nothing Then m_objSlider.HandleNavigationComplete
End Sub


Private Sub m_objSlider_ValueChanged(ByVal lngValue As Long)
    Me.txtOutput.Value = lngValue
End Sub


Private Sub Form_Close()
On Error Resume Next
    Me.TimerInterval = 0
    If Not m_objSlider Is Nothing Then m_objSlider.Destroy
    Set m_objSlider = Nothing
End Sub

Optional lassen sich webSlider_MouseUp und webSlider_Click ergänzen, die jeweils m_objSlider.FlushNow aufrufen.

Für einen zweiten Slider auf demselben Formular genügt eine zweite clsSlider-Instanz mit einem eigenen Steuerelement und einem eigenen Namen - clsWebBridge legt die Temp-Datei je Control-Name an, und beide Instanzen arbeiten unabhängig voneinander.

Download

Voraussetzungen: Microsoft 365, Access 2024+, 32/64-bit

Loading...
👁
0
Views
💬
0
Comments
Open
YouTube
Published: ...
Last updated: ...
Newsletter

Newsletter