Erstellen eines funktionierenden Instagram-Klons mit Flutter und Firebase

Dies ist eine Übersicht darüber, wie Flutter und Firebase zum Erstellen einer Foto-Sharing-Anwendung verwendet wurden.

Oh nein, noch ein Instagram-Klon?

Die meisten Klone, auf die ich gestoßen bin, sind entweder nur UI-Herausforderungen oder es fehlen Funktionen. Dieses Projekt bietet jedoch eine umfassendere Instagram-Erfahrung mit Feeds, Kommentaren, Geschichten, Direktnachrichten, Push-Benachrichtigungen, Löschen von Posts, Benutzerberichten, Datenschutz für Konten und vielem mehr. Es steht auch zum Download auf iOS und Android zur Verfügung.

Sie können die Anwendung hier herunterladen.

Ich werde mich nur auf die Kernthemen konzentrieren und Themen wie Firebase Auth, Cloud Storage und Firebase Cloud Messaging überspringen, da es bereits mehrere Artikel und Tutorials dazu gibt.

Ich werde auch die meisten Elemente der Benutzeroberfläche nicht diskutieren, außer denen, die ich am schwierigsten finde.

In jedem Abschnitt werde ich versuchen, die wichtigsten Imbissbuden hervorzuheben.

Projektarchitektur

Dieser Abschnitt soll nur einen kurzen Überblick über das Projekt geben.

Die Projektarchitektur ist einfach und besteht aus zwei Hauptordnern: Benutzeroberfläche und Dienste.

Projektstruktur

Der UI-Ordner ist in drei Teile unterteilt: Bildschirme, Widgets und freigegeben.

Bildschirme sind Widgets der obersten Ebene, die die gesamte Benutzeroberfläche auf einem Bildschirm des Geräts anzeigen.

Manchmal enthalten Bildschirme Widgets mit viel Code, sodass diese Widgets in ihre jeweiligen Dateien im Widgets-Ordner abstrahiert werden.

Einige Widgets werden in mehreren Bildschirmen wiederverwendet, z. B. in einer Ladeanzeige oder einer benutzerdefinierten Bildlaufansicht. Diese Widgets werden im freigegebenen Ordner abgelegt.

Der Dienstordner enthält Dateien, die Firebase-Dienste wie Firestore, Cloud Storage und Firebase Auth verarbeiten.

Es enthält auch das Repository, eine Abstraktionsschicht, auf die die App-Benutzeroberfläche zugreifen kann. Für jede Funktion in einer der Servicedateien, die in der App-Benutzeroberfläche benötigt wird, gibt es im Repository eine Funktion, die auf diese Funktion verweist.

In jeder Datei der App-Benutzeroberfläche (Bildschirme und Widgets), die Cloud-Dienste verwenden muss, wird nur das Repository importiert.

Widgets kennen die anderen Dateien im Dienstordner nicht.

Datenmodellierung

Der Modellordner, der die Datenobjekte enthält: Benutzer, Beitrag, Kommentar usw.

Die Datenobjekte, die aus dem Firestore abgerufen werden sollen, verfügen normalerweise über einen Hilfsfactory-Konstruktor, der einen DocumentSnapshot als Parameter verwendet:

factory Post.fromDoc (DocumentSnapshot doc) {return Post (ID: doc.documentID, ..., Zeitstempel: doc ['Zeitstempel'], Metadaten: doc ['Daten'] ?? {}, Beschriftung: doc ['Beschriftung '] ??' ',); }}

Auf diese Weise können wir Post-Objekte wie folgt aus Firestore-Dokumenten abrufen:

/// Mock-Funktion final QuerySnapshot snap = warte auf shared.collection ('posts'). Get ();
endgültige Liste posts = snap.docs.map ((doc) => Post.from (doc)). toList ();

Datenbank Design

Die für dieses Projekt verwendete Datenbank ist Firestore. Warum? Weil…

  1. Firestore ist der neuere Cousin der Echtzeitdatenbank und bietet eine bessere Skalierbarkeit und Datenmodellierung. Mehr Infos hier.
  2. Firebase ist ein früher Unterstützer von Flutter und stellt sein Plugin von Anfang an auf pub.dev zur Verfügung.
  3. Liest> Schreibt.

Der Hauptansatz besteht darin, die Datenbank so zu strukturieren, dass die erforderlichen Daten leicht abgerufen werden können. Das größte zu überwindende Problem war die eingeschränkte Abfragefähigkeit von Firestore.

Im Gegensatz zu anderen Datenbanklösungen können Sie nicht einfach so etwas wie "Letzte Beiträge von Benutzern abrufen, denen ich folge" für den Feed-Bildschirm abfragen.

Das A-ha! In diesem Moment wurde mir klar, dass ein Bildschirm nur Daten aus einem einzelnen Daten-Bucket abrufen sollte. In diesem Fall läuft die Abfrage einfach darauf hinaus, "Dokumente aus meiner Feed-Sammlung abzurufen".

Liest> schreibt in einer Social-Media-App wie Instagram. Ein Benutzer kann Hunderte von Dokumenten lesen, bevor er einen einzelnen Schreibvorgang ausführt (posten, mögen, kommentieren, folgen / nicht folgen).

Aus diesem Grund ist es eine gute Idee, die ganze harte Arbeit innerhalb der Schreibvorgänge zu erledigen und die Lesevorgänge einfach zu halten.

Wenn ein Benutzer einen Beitrag hochlädt, wird der Beitrag in den Feed jedes einzelnen Followers geschrieben. Mit anderen Worten, Daten werden dupliziert.

Ja, dies bedeutet, dass ein Benutzer mit Millionen von Followern viel mehr kostet als der typische Benutzer. Das Schreiben an eine Million Follower-Feeds kostet ungefähr 1,80 US-Dollar, aber der Wert, den jemand, der so viele Follower gesammelt hat, auf die soziale Plattform bringt, ist wahrscheinlich viel größer.

Benutzer / {Benutzer-ID} / Feed / {Post-ID}

Auf diese Weise können Sie problemlos Dokumente aus einer einzelnen Sammlung abrufen, die nach dem Upload-Datum sortiert sind, und bei Bedarf paginieren.

Der Nachteil dieser Datenbankstruktur besteht darin, dass aufgrund der Duplizierung von Daten zusätzliche Schritte erforderlich sind, wenn ein Benutzer beispielsweise Änderungen an einem Dokument vornimmt.

Was passiert, wenn der Benutzer die Beschriftung eines Beitrags ändert oder, schlimmer noch, Änderungen an seinem Profilbild oder Benutzernamen vornimmt? Wie würden sich diese Änderungen auf frühere Beiträge im Feed ihrer Follower auswirken? Wie würden Sie einen Beitrag zum Feed eines Followers schreiben?

Geben Sie Cloud-Funktionen ein.

Cloud-Funktionen

Die Hauptidee besteht darin, einen vertrauenswürdigen Server zum Bereitstellen von Code zu verwenden und das Schreiben von clientseitigem Code zu vermeiden, wenn Sie können.

Wie würden Sie in den Feed aller schreiben, wenn ein neuer Beitrag hochgeladen wird?

Ein Benutzer kann Tausende von Followern und Tausende von Posts haben. Wenn der Benutzer Änderungen an seinem Profil oder einem seiner Beiträge vornimmt, müssen wir diese Änderungen auch an den Feed jedes Followers weitergeben.

Diese Art von Operation wird als Fan-Out-Operation bezeichnet, bei der ein Dokument über mehrere Knoten (Referenzen) in der Datenbank dupliziert wird.

So fächern Sie einen neuen Beitrag in den Feed der Follower auf:

  1. Holen Sie sich die Follower des Post-Uploaders.
  2. Erstellen Sie für jeden Follower eine Dokumentreferenz unter Verwendung der Post-ID für die Dokument-ID.
  3. Schreiben Sie die Post-Daten in diese Referenz
  4. Optional - Schreiben Sie den neuen Beitrag in Ihren eigenen Feed

Bei jedem Fan-Out-Vorgang empfiehlt es sich, gestapelte Schreibvorgänge zu verwenden. Jede Charge kann jedoch maximal 500 Vorgänge haben, sodass Sie mehrere Chargen für einen Fan-Out-Vorgang benötigen, der mehr als das erfordert.

Eine andere Verwendung der Cloud-Funktion besteht darin, den ähnlichen Zähler eines Posts zu aktualisieren.

Die Anzahl der Posts kann missbraucht werden, wenn sie durch clientseitigen Code gesteuert werden. Stattdessen verwenden wir eine Cloud-Funktion, die ein Dokument abhört, das in der Subkollektion "Gefällt mir" erstellt oder gelöscht wird, und den Like-Zähler des Posts mithilfe von FieldValue.increment entsprechend ändert:

Eine wichtige Verwendung der Cloud-Funktion ist das Senden von Push-Benachrichtigungen über Firebase Cloud Messaging (FCM). Diese sendFCM-Funktion wird in jeder relevanten exportierten Funktion aufgerufen (post like, comment like, follow event, Kommentarantwort, Direktnachricht):

Das Backend ist das Rückgrat einer App. Sie müssen Ihre Datenbank entsprechend den Abfrageanforderungen der App richtig planen und strukturieren. Bevor Sie Ihre App hübsch machen, lassen Sie sie funktionieren.

Kommen wir zur UI-Seite der Dinge. Der UI-Abschnitt enthält auch einige Daten zum Datenbankdesign, die ich zuvor nicht behandelt habe.

Root PageView und Homepage

Das Root-Widget ist eine Seitenansicht, wobei die erste Seite der Editor, die zweite die Haupt-Homepage mit allen Navigationsregistern und die dritte der Direktnachrichtenbildschirm ist.

1. Editor 2. Home 3. Direktnachrichten

Sie können zwischen den Seiten wechseln, indem Sie wischen oder die obersten Navigationstasten drücken. Setzen Sie die Startseite der Seitenansicht auf 1, die Homepage.

Wenn Sie das Navigationsverhalten in der iOS-App von Instagram nachahmen möchten, sollten Sie für jeden Tab auf der Startseite ein CupertinoTabScaffold und ein CupertinoTabView verwenden. Jede Registerkartenansicht verwaltet ihren eigenen Navigationsstapel. Dies ist wichtig, wenn Sie mehrere Registerkarten gleichzeitig durchsuchen möchten.

Bei der Verwendung von CupertinoTabView stieß ich jedoch auf einen seltsamen Fehler bei der Fokussierung auf das Textfeld auf dem Editorbildschirm. Daher verwendete ich eine benutzerdefinierte Navigationslösung von Andrea Bizzotto, mit der der Fehler behoben wurde.

Um den Navigationsstapel auf der untersten Route der Startseite zu platzieren, müssen Sie für jede Registerkartenansicht einen globalen Navigatorschlüssel erstellen:

Karte > _navigatorKeys = {TabItem.feed: GlobalKey (), TabItem.search: GlobalKey (), TabItem.create: GlobalKey (), TabItem.activity: GlobalKey (), TabItem.profile: GlobalKey (),};

Ordnen Sie die Registerkartenansicht mit einer Navigationstaste zu. Sie müssen dies auch tun, wenn Sie CupertinoTabView verwenden.

/// Registerkarte Start (Feed)
Navigator (Schlüssel: _navigatorKeys [TabItem.feed], ...)
/// Registerkarte "Suchen"
Navigator (Schlüssel: _navigatorKeys [TabItem.search], ...)

Anschließend verwenden Sie den onTap (Index) -Rückruf der BottomNavigationBar, um auszuwählen, welchen Stapel Sie einfügen möchten:

/// Stellen Sie sicher, dass die Registerkarte, die Sie drücken, die aktuelle Registerkarte ist, wenn (tab == currentTab)
/// Pop bis zum ersten _navigatorKeys [tab] .currentState .popUntil ((route) => route.isFirst);

Möchten Sie, dass ein Bildschirm nach oben scrollt? Sie müssen für jede Registerkarte einen Bildlauf-Controller erstellen:

final feedScrollController = ScrollController (); .... final profileScrollController = ScrollController ();

Weisen Sie das Haupt-Widget für die Bildlaufansicht einem Bildlauf-Controller zu (ignorieren Sie initialRoute und onGenerateRoute, wenn Sie CupertinoTabView verwenden):

/// My Profile Screen Navigator (Schlüssel: _navigatorKeys [TabItem.profile], initialRoute: '/', onGenerateRoute: (routeSettings) {MaterialPageRoute zurückgeben (Builder: (context) => MyProfileScreen (scrollController: profileScrollController,),}; ,),

Im onTap (Index) -Rückruf von BottomNavigationBar können Sie auswählen, zu welchem ​​Controller Sie nach oben scrollen möchten:

if (tab == currentTab) {
switch (tab) {case TabItem.home: controller = feedScrollController; brechen; ... case TabItem.profile: controller = profileScrollController; brechen; } /// Nach oben scrollen if (controller.hasClients) controller.animateTo (0, Dauer: scrollDuration, Kurve: scrollCurve); }}

Futter

Das Haupt-Widget ist das Post-Listenelement-Widget:

  1. Header
  2. Foto PageView
  3. Verlobungsleiste (Schaltflächen "Gefällt mir", "Kommentieren" und "Teilen")
  4. Bildunterschrift (nicht gezeigt)
  5. Wie Zählleiste
  6. Kommentarzählleiste
  7. Beste Kommentare
  8. Zeitstempel

Wenn Sie sich fragen: Mit der Schaltfläche, die wie ein Whirlpool aussieht, können Sie einen Beitrag kritzeln. Sie können sehen, was andere Leute gezeichnet haben.

Die Daten für Header, Fotoseitenansicht, Beschriftung und Zeitstempel können direkt aus dem Post-Dokument in der Feed-Sammlung abgerufen werden: users / {userId} / feed / {postId}.

Die Verlobungsleiste ist wegen der Schaltfläche "Gefällt mir" schwierig. Die Schaltfläche "Gefällt mir" ändert sich in der Farbe, je nachdem, ob Ihnen der Beitrag gefallen hat oder nicht.

Erstellen Sie zunächst eine Funktion, die mithilfe der firestore snapshots () -Eigenschaft einen Stream eines DocumentSnapshot zurückgibt:

/// Überprüfen Sie, ob der aktuelle Benutzer einen Beitrag gemocht hat /// Gibt einen Stream zurück, sodass didLike = snapshot.data.exists ///auth.uid auf die Benutzer-ID des aktuell angemeldeten Benutzers verweist
Strom myPostLikeStream (Post post) {final ref = postRef (post.id) .collection ('liks'). document (auth.uid); return ref.snapshots (); }}

Verwenden Sie diesen Stream in einem StreamBuilder, um eine reaktive Benutzeroberfläche anzuzeigen, die korrekt wiedergibt, ob die Schaltfläche "Gefällt mir" gedrückt wurde oder nicht:

StreamBuilder (Stream: Repo.myPostLikeStream (Post), Builder: (Kontext, Snapshot) {if (! snapshot.hasData) return SizedBox ();
      /// Wenn das Dokument vorhanden ist, wurde der Beitrag vom aktuell /// angemeldeten Benutzer gemocht
final didLike = snapshot.data.exists; return LikeButton (onTap: () {return didLike? Repo.unlikePost (post): Repo.likePost (post);},
        /// Schaltflächenaussehen
icon: didLike? FontAwesome.heart: FontAwesome.heart_o, Farbe: didLike? Colors.red: Colors.black,); }),

Das Schöne daran ist, dass selbst wenn Sie denselben Beitrag auf mehreren Bildschirmen oder Geräten betrachten, der Status aller ähnlichen Schaltflächen auf mehreren Bildschirmen korrekt wiedergegeben wird.

Als zusätzlichen Bonus reagiert die Schaltfläche "Gefällt mir" aufgrund der Offline-Funktionen des Firestores auch dann auf Benutzerdrücke, wenn Sie offline sind!

Sie können dasselbe Prinzip auf ähnliche Widgets anwenden wie die Schaltfläche "Folgen / Entfolgen" auf der Profilseite.

Manchmal gibt es Teile der Benutzeroberfläche, die je nach Benutzer unterschiedlich angezeigt werden. Versuchen Sie in diesen Fällen, einen StreamBuilder für eine reaktive Erfahrung zu verwenden.

Wenn die Benutzeroberfläche hauptsächlich statisch ist und für alle Benutzer gleich ist (z. B. Bildunterschriften oder Fotos), können Sie Daten einfach auf normale Weise abrufen.

Die Post-Statistiken (wie Anzahl, Anzahl der Kommentare) werden an anderer Stelle gespeichert und müssen separat abgerufen werden. Diese Postsammlung existiert als separate Sammlung auf Stammebene und ist keine Untersammlung der Benutzersammlung.

Zukunft getPostStats (String postId) async {final ref = shared.collection ('posts'). document (postId); final doc = warte auf ref.get (); return! doc.exists? PostStats.empty (postId): PostStats.fromDoc (doc); }}

Warum speichern Sie die Statistiken nicht an derselben Stelle, an der sich das Postdokument befindet?

Weil die Statistiken sehr anfällig für Änderungen sind und daher nicht dupliziert werden sollten.

Können Sie sich vorstellen, den Feed jedes Followers jedes Mal zu aktualisieren, wenn ein Beitrag gefällt oder jemand einen Kommentar abgibt?

Für die Top-Kommentare müssen Sie die Kommentare in der Kommentarsubkollektion des Beitrags abfragen:

Zukunft > getPostTopComments (String postId, {int limit}) async {final ref = shared .collection ('posts') .document (postId) .collection ('Kommentare') .orderBy ('like_count', absteigend: true) .limit ( Grenze ?? 2); final snap = warte auf ref.getDocuments (); return snap.documents.map ((doc) => Comment.fromDoc (doc)). toList (); }}

Ja, Sie können versuchen, die Top-Kommentare als Array im selben Post-Dokument zu speichern. Sie müssen jedoch eine komplexe Cloud-Funktion schreiben, die das Erstellen oder Löschen von Kommentaren abhört und Änderungen in der Anzahl der Likes abhört Aktualisieren / Sortieren des Top-Kommentar-Arrays des Beitrags nach Bedarf. Darüber hinaus müssen Sie auch den Feed jedes Followers aktualisieren.

Dies bedeutet, dass mehrere Lesevorgänge erforderlich sind, um einen einzelnen Beitrag abzurufen. Dies ist absolut in Ordnung, wenn man bedenkt, dass alle Daten in einem Dokument zusammengefasst werden können und möglicherweise Tausende von Dokumenten für jedes einzelne Like, jeden Kommentar oder jede Änderung aktualisiert werden müssen. Denken Sie daran, dass nicht alle Ihre Follower Ihren neuen Beitrag lesen / abrufen, aber jede Fan-Out-Operation muss an jeden einzelnen Ihrer Follower weitergegeben werden.

Um die Lesevorgänge zu optimieren, könnten Sie stattdessen feststellen, dass Sie die Dinge viel schwieriger und kostspieliger machen, als es sein sollte.

Sie sollten sich das Post-Widget nicht als ein einzelnes Widget vorstellen, sondern als mehrere Widgets, die jeweils Daten aus verschiedenen Quellen oder „Buckets“ enthalten. Dies hilft auch bei der Datenmodellierung, da mehrere Datenobjekte anstelle eines einzelnen übermäßig aufgeblähten Post-Objekts verwendet werden.

Staatsverwaltung

Ich habe versucht, BloC- und Provider-Pakete zu verwenden, um den Anwendungsstatus beizubehalten. Ich fand jedoch, dass die Verwendung eines StreamBuilder für dieses Projekt einfacher ist, insbesondere wenn man bedenkt, dass der Firestore bereits Streams von DocumentSnapshot (einzelnes Dokument) und QuerySnapshot (mehrere Dokumente) bereitstellt.

In einigen Fällen fand ich die Verwendung eines EventBus nützlich, insbesondere wenn Sie nach einem erfolgreichen Post-Upload oder -Löschen Toastnachrichten anzeigen oder die Benutzeroberfläche aktualisieren müssen.

In den meisten Widgets, in denen Sie Daten nur einmal laden und keine Dokumentänderungen abhören müssen, können Sie einfach setState () verwenden.

Nehmen Sie zum Beispiel das Widget, das die Beiträge eines anderen Benutzers auf seiner Profilseite anzeigt. Rufen Sie in initState () eine Funktion auf, die Beiträge abruft:

@override initState () {_getPosts ();
super.initState (); }}

Die Benutzeroberfläche aktualisiert und zeigt beim Aufruf von setState () automatisch Beiträge an:

_getPosts () async {setState (() {isLoadingPosts = true;});
  endgültiges PostCursor-Ergebnis = warte auf Repo.getPostsForUser (uid: uid, limit: 8,);
if (gemountet) setState (() {isLoadingPosts = false; posts = result.posts; startAfter = result.startAfter;}); }}

Das PostCursor-Objekt ist eine Hilfsklasse für die Paginierung, auf die ich später zurückkommen werde. Es enthält einfach eine Liste der Beiträge Liste und ein DocumentSnapshot des zuletzt abgerufenen Dokuments.

Die Variable isLoadingPosts ist nur ein Flag, das der Benutzeroberfläche mitteilt, wann ein Ladeindikator angezeigt werden soll.

Dieses Muster zum Abrufen von Daten in initState () und zum anschließenden Aktualisieren der Benutzeroberfläche mit abgerufenen Daten finden Sie in vielen anderen Bildschirmen.

Es wird immer empfohlen, vor dem Aufrufen von setState () zu überprüfen, ob die Eigenschaft (gemountet) ist. Wenn Sie if nicht mehrmals mounten möchten, überschreiben Sie einfach setState () Ihres StatefulWidget:

@override void setState (fn) {if (gemountet) super.setState (fn); }}

Manchmal reicht es nicht aus, setState () aufzurufen. Ein verwandtes Beispiel wäre der aktuell angemeldete Benutzerprofilbildschirm, auf dem wir nicht nur Beiträge abrufen, sondern auch die Benutzeroberfläche entsprechend aktualisieren müssen, wenn ein Beitrag hochgeladen wird.

Wir können hierfür einen StreamBuilder verwenden, es ist jedoch schwierig, mit einem StreamBuilder zu paginieren. Sie möchten Ihre Daten nicht paginieren? Was passiert, wenn der aktuell angemeldete Benutzer Tausende von Posts hat? Jedes Mal, wenn der Profilbildschirm geladen wird, werden alle Beiträge gleichzeitig über den Stream geladen. Dies ist sowohl aus Abrechnungs- als auch aus Bandbreitensicht kostspielig.

Die Lösung? Verwenden Sie eine Kombination aus Stream und setState ().

Wie im vorherigen Beispiel rufen wir beim Laden zuerst einige Beiträge ab. Verwenden Sie außerdem einen Stream, um neue Beiträge in der Datenbank anzuhören und den Beitrag zur Benutzeroberfläche hinzuzufügen.

Erstellen Sie den Stream und fügen Sie ihm in initState () einen Listener hinzu. Wenn ein neuer Beitrag hochgeladen wird, aktualisieren Sie die Benutzeroberfläche:

final postStream = Repo.myPostStream ();
Liste posts = [];
@override void initState () {_getPosts (); postStream.listen ((data) {data.documents.forEach ((doc) {if (initialPostsLoaded) {final post = Post.fromDoc (doc); if (post == null) return; setState (() {posts = [ post] + posts;});}});}); eventBus.on () .listen ((event) {setState (() {posts = List .from (posts) ..removeWhere ((p) => p.id == event.postId); }); }); super.initState (); }}

Der Stream hört nur das neueste Dokument in der Postsammlung:

Strom myPostStream () {final ref = userRef (auth.uid) .collection ('posts') .orderBy ('timestamp', absteigend: true) .limit (1); return ref.snapshots (); }}

Für Post-Löschungen verwende ich einen Event-Bus-Listener. Wenn ein Beitrag gelöscht ist, sucht die Benutzeroberfläche nach einem Beitrag mit der ID und entfernt ihn aus der Ansicht.

Es gibt viele Möglichkeiten, den Status zu verwalten. Es gibt einige beliebte State-Management-Lösungen, die ich nicht ausprobiert habe, wie RxDart. Wählen Sie diejenige, die das erreicht, was Sie wollen, ohne übermäßig kompliziert zu sein. Verwenden Sie BloC nicht für eine einfache Zähler-App - meistens reicht setState () aus. Denken Sie aber auch darüber nach, ob die von Ihnen gewählte Lösung auch in Zukunft handhabbar ist.

Seitennummerierung

Ich habe eine einfache Hilfsklasse erstellt, um bei der Paginierung von Posts zu helfen.

Klasse PostCursor {endgültige Liste Beiträge; final DocumentSnapshot startAfter; final DocumentSnapshot endAt; PostCursor (this.posts, this.startAfter, this.endAt); }}

Wir können diese Klasse in unseren Servicedateien wie folgt verwenden:

Zukunft getFeed ({DocumentSnapshot startAfter}) async {final uid = Auth.prof.uid; letzte Abfrage = startAfter == null? userRef (uid) .collection ('feed') .orderBy ('created_at', absteigend: true) .limit (8): userRef (uid) .collection ('feed') .orderBy ('created_at', absteigend: true) .limit (14) .startAfterDocument (startAfter); final docs = warte auf query.getDocuments (); final posts = docs.documents.map ((doc) => Post.fromDoc (doc)). toList (); return docs.documents.isNotEmpty? PostCursor (posts, docs.documents.last, docs.documents.first): PostCursor (posts, startAfter, null); }}

Auf diese Weise müssen Bildschirme nur ein einziges Datenobjekt behandeln, das alle erforderlichen Daten enthält, um Widgets anzuzeigen und zu paginieren. Minimieren Sie die Geschäftslogik in Ihren Widgets.

Für Bildschirme, die sowohl aktualisiert werden müssen als auch mehr Funktionen laden, habe ich eine benutzerdefinierte Bildlaufansicht erstellt, die auf einen Überlauf reagiert. Sie können andere Bibliotheken verwenden, um die gleichen Ergebnisse zu erzielen.

Die Hauptsache ist, eine CustomListView zu verwenden und ihre Splitter zu deklarieren.

Ich habe ein CupertinoSliverRefreshControl für ein iOS-Look & Feel für die Pull-to-Refresh-Funktion verwendet.

Platzieren Sie Ihre ListView oder ein Widget, das ScrollView erweitert, im SliverToBoxAdapter.

Zuletzt platzieren Sie eine Ladeanzeige in einem anderen SliverToBoxAdapter und zeigen sie nur an, wenn auf dem Bildschirm mehr Daten geladen werden.

Da das Standardverhalten von ClampingScrollPhysics () unter Android nicht das gewünschte ist, müssen wir BouncingScrollPhysics () angeben, damit die Rückrufe onRefresh und onLoadMore vom Overscroll aufgerufen werden können.

Der Nachteil bei der Verwendung ist, dass Sie jedes Mal, wenn Sie eine ListView in CustomScrollView platzieren, shrinkWrap: true festlegen müssen, wodurch die Leistung verringert wird. Setzen Sie außerdem die Physik der ListView auf NeverScrollablePhysics (), wenn Sie nicht möchten, dass sie unabhängig von ihrem übergeordneten Element scrollt.

Direct Messaging-Bildschirm

Ein Chat-Bildschirm allein würde ausreichen, wenn Sie nur durch Drücken der Nachrichtentaste in einem Benutzerprofil darauf zugreifen können. Genau wie bei Instagram möchten wir jedoch einen Direct Messaging-Bildschirm (DM), auf dem alle aktiven Chats angezeigt werden.

Der DM-Daten-Bucket enthält nicht die tatsächlichen Nachrichten, sondern verwaltet Dokumente, die Benutzer enthalten, mit denen Sie in der Vergangenheit geplaudert haben.

Darüber hinaus enthält jedes Dokument mehrere Felder, die wichtige Informationen enthalten.

Das Feld last_checked enthält die letzte in der Konversation gesendete Nachricht.

Der last_seen_timestamp bezieht sich auf das letzte Mal, dass der Benutzer die Konversation geöffnet hat.

Da der DM-Bildschirm auf neue Nachrichten reagieren muss, verwenden wir einen Stream und einen StreamBuilder, um Daten in eine ListView einzuspeisen. Wir sortieren die Daten auch nach der Aktualität des last_checked_timestamp, sodass die neuesten Konversationen oben angezeigt werden.

StreamBuilder (Stream: Repo.DMStream (), Builder: (Kontext, Snapshot) {if (! snapshot.hasData) {return LoadingIndicator ();} else {final docs = snapshot.data.documents ..sort ((a, b)) {final Timestamp aTime = a.data ['last_checked_timestamp']; final Timestamp bTime = b.data ['last_checked_timestamp']; return bTime.millisecondsSinceEpoch .compareTo (aTime.millisecondsSinceEpoch);});
return docs.isEmpty? EmptyIndicator ('Keine Konversationen zu zeigen'): ListView.builder (shrinkWrap: true, Physik: NeverScrollableScrollPhysics (), itemBuilder: (Kontext, Index) {final doc = docs [index]; ...

Um eine Konversation mit ungelesenen Nachrichten in unserer ListView zu markieren, prüfen wir, ob der last_checked_timestamp größer als unser last_seen_timestamp ist.

final Timestamp lastCheckedTimestamp = doc ['last_checked_timestamp'];
final Timestamp lastSeenTimestamp = doc ['last_seen_timestamp'];
///auth.uid bezieht sich auf die aktuell angemeldete Benutzer-ID
final hasUnread = (lastSeenTimestamp == null) // Wenn kein last_seen_timestamp vorhanden ist, muss es sich um eine neue Konversation handeln? lastCheckedSenderId! = auth.uid // Wenn ich die Nachricht nicht gesendet habe, überprüfe, ob ich die letzte // überprüfte Nachricht gesehen habe: (lastSeenTimestamp.seconds 

Der last_seen_timestamp wird jedes Mal aktualisiert, wenn der Chat-Bildschirm geöffnet wird und eine neue Nachricht vom anderen Benutzer eingeht.

Jetzt haben wir alle notwendigen Informationen, um unsere Konversationen anzuzeigen, sortiert nach Aktualität und ungelesenen Nachrichten:

Ungelesene Gespräche sind mit einem blauen Indikator mit fettem Text gekennzeichnet

Zuletzt habe ich das Paket flutter_slidable verwendet, damit Benutzer Konversationen löschen können, indem sie das Listenansichtselement verschieben.

Das Feld is_persisted gibt an, ob die Konversation vom DM-Bildschirm eines Benutzers gelöscht wurde. Der Stream, der den DM-Bildschirm mit Strom versorgt, ruft Dokumente ab, für die dieses Feld auf true gesetzt ist.

Strom DMStream () {return userRef (auth.uid) .collection ('chats') .where ('is_persisted', isEqualTo: true) .snapshots (); }}

Wenn ein Benutzer eine Konversation löscht, wird das Feld is_persisted auf false gesetzt und ein neues Feld end_at hinzugefügt, dessen Wert auf Timestamp.now () gesetzt ist.

Zukünftige deleteChatWithUser (String userId) async {final selfId = auth.uid;
final selfRef = userRef (selfId) .collection ('chats'). document (userId);
endgültige Nutzlast = {'is_persisted': false, 'end_at': Timestamp.now (),};
return selfRef.setData (Nutzlast, Zusammenführung: true); }}

In diesem neuen Feld können wir Nachrichten nur bis zu einem bestimmten Datum laden, wenn der Benutzer beschließt, die Konversation erneut zu öffnen, nachdem sie am Ende gelöscht wurde.

Beachten Sie, dass die tatsächlichen Nachrichten nicht gelöscht werden. Stattdessen wird nur die Chat-Sitzung vom DM-Bildschirm dieses Benutzers entfernt, und der Benutzer sieht niemals Nachrichten vor dem Löschdatum. Dies ahmt das Verhalten von Instagram nach.

Chat-Bildschirm

Wenn ein Benutzer auf ein Element auf dem DM-Bildschirm tippt, wird er zum Chat-Bildschirm geleitet, auf dem die tatsächlichen Nachrichten angezeigt werden.

Der erste Schritt vor dem Laden der Nachrichten besteht darin, die Chat-ID abzurufen. Die Chat-ID ist eine Kombination aus zwei Benutzer-IDs. Auf diese Weise können wir problemlos auf eine Konversation verweisen, ohne eine zusätzliche Abfrage durchführen zu müssen. In initState () nehmen wir beide Benutzer-IDs, sortieren sie und verbinden sie mit einem Bindestrich:

chatId = (uid.hashCode <= peerId.hashCode)? '$ uid- $ peerId': '$ peerId- $ uid';

Wir prüfen dann, ob die Konversation zuvor gelöscht wurde, indem wir prüfen, ob ein end_at-Wert vorhanden ist. Sobald wir sowohl chatId als auch end_at erhalten haben, können wir endlich die Nachrichten abrufen:

Zukunft getInitialMessages () async {final end = warte auf Repo.chatEndAtForUser (peer.uid);
final initialMessages = warte auf Repo.getMessages (chatId: chatId, endAt: end); endAt = end; if (initialMessages.isNotEmpty) {startAt = initialMessages.last.timestamp; } setState (() {messages.addAll (initialMessages); initialMessagedFinishedLoading = true;}); Rückkehr; }}

Dies ist die Funktion getMessages. Im Gegensatz zu Posts paginieren wir die Firestore-Abfrage mit Timestamp anstelle von DocumentSnapshot:

Zukunft > getMessages (String chatId, Timestamp endAt, {Timestamp startAt}) async {final ref = shared.collection ('chats'). document (chatId); Endgrenze = 20; Abfrage Abfrage; query = endAt == null? query = ref .collection ('messages') .orderBy ('timestamp', absteigend: true) .limit (limit): startAt == null? ref .collection ('messages') .orderBy ('timestamp', absteigend: true) .endAt ([endAt]). limit (limit): ref .collection ('messages') .orderBy ('timestamp', absteigend: true ) .startAfter ([startAt]). endAt ([endAt]). limit (limit); final snap = warte auf query.getDocuments (); return snap.documents.map ((doc) => ChatItem.fromDoc (doc)). toList (); }}

Die Chat-Sammlung befindet sich auf der Stammebene und enthält die Nachrichten-Untersammlung, die alle Nachrichten und deren Inhalt enthält:

Datenfenster des Chat-Bildschirms

Sobald wir die Nachrichten haben, können wir sie endlich auf dem Chat-Bildschirm anzeigen:

Jedes Element, das Sie in der Chat-ListView sehen, wird von einem ChatItem-Datenobjekt angetrieben, das aus dem Firestore abgerufen wird. Wir verwenden eine switch-Anweisung, um das ChatItem basierend auf seinem Typ, den wir zum Auffüllen der ListView verwenden, in das entsprechende UI-Element zu konvertieren. Dies umfasst die weiße Peer-Textblase, die blaue Benutzertextblase, den Zeitstempel und die Anzeige für die Eingabe.

Die chatListView ist einzigartig, da sie Nachrichten von unten lädt und mehr Nachrichten paginiert / lädt, wenn der Benutzer nach oben scrollt. Im Wesentlichen ist die ListView invertiert. Glücklicherweise hat ListView eine praktische Eigenschaft umgekehrt, die wir auf true gesetzt haben, um diesem Verhalten Rechnung zu tragen.

Wir müssen den richtigen Ort finden, um den Peer-Avatar und die Zeitstempel hinzuzufügen. Im itemBuilder der ListView:

itemBuilder: (Kontext, Index) {letzte Nachricht = Nachrichten [Index]; final isPeer = message.senderId! = auth.uid; /// Denken Sie daran: Listenansicht ist umgekehrt final isLast = index <1;
final lastMessageIsMine = isLast &&! isPeer; final nextBubbleIsMine = (! isLast && messages [index - 1] .senderId == auth.uid); final showPeerAvatar = (isLast && message.senderId == peer.uid) || nextBubbleIsMine; /// Nachrichtendatum anzeigen, wenn vorherige Nachricht /// vor mehr als einer Stunde gesendet wurde final isFirst = index == messages.length - 1; final currentMessage = messages [index]; final previousMessage = isFirst? null: Nachrichten [Index + 1];
/// Zeitstempel anzeigen bool showDate; if (previousMessage == null) {showDate = true; } else if (currentMessage .timestamp == null || previousMessage.timestamp == null) {showDate = true; } else {showDate = previousMessage .timestamp.seconds 

Sobald Sie die booleschen Eigenschaften showPeerAvatar und showDate festgelegt haben, können Sie sie in das Widget einfügen, mit dem Sie die ListView füllen.

Möchten Sie anzeigen, ob ein Benutzer tippt? Zuerst hören wir uns einen TextEditingController an, der an das TextField-Widget angehängt ist. Rufen Sie diese Funktion in unserem initState auf:

/// Aktuell angemeldete Benutzer-UID String uid = FirestoreService.ath.uid;
/// Hören Sie zu, ob Peer listenToTypingEvent () {textController.addListener (() {if (textController.text.isNotEmpty) {/// eingibt. Verwenden Sie ein Flag, um sicherzustellen, dass dies nicht mehrmals aufgerufen wird
if (! selfIsTyping) {print ('tippt'); Repo.isTyping (chatId, uid, true); } selfIsTyping = true; } else {selfIsTyping = false; print ('nicht tippen!'); Repo.isTyping (chatId, uid, false); }}); }}

Vergessen Sie nicht, die Tippaktivität nicht mehr anzuzeigen, wenn der Chat-Bildschirm geschlossen ist. Wir überschreiben die dispose () -Methode:

@override void dispose () {print ('Chat-Bildschirm entsorgen'); Repo.isTyping (chatId, uid, false); super.dispose (); }}

Die isTyping-Funktion schreibt in die is_typing-Subkollektion des Chats. Es müssen keine Daten festgelegt werden. Stellen Sie lediglich sicher, dass das Dokument mit der Benutzer-ID als Dokument-ID erstellt oder gelöscht wird:

isTyping (String chatId, String uid, bool isTyping) {final ref = shared.collection ('chats'). document (chatId) .collection ('is_typing'); return isTyping? ref.document (uid) .setData ({}): ref.document (uid) .delete (); }}

Auf diese Weise können wir einen Stream erstellen, der abhört, welche Chat-Teilnehmer Folgendes eingeben:

Strom isTypingStream (String chatId) {return shared .collection ('chats') .document (chatId) .collection ('is_typing') .snapshots (); }}

In unserem Chat-Bildschirm hören wir diesen Stream, der eine Tippanzeige einfügt oder entfernt:

_isTypingStream = Repo.isTypingStream (chatId); _isTypingStream.listen ((data) {if (! mount) return; final uids = data.documents.map ((doc) => doc.documentID) .toList (); print (uids); if (uids.contains (Peer) .uid)) {/// Stellen Sie sicher, dass nur ein Tippindikator sichtbar ist, wenn (! peerIsTyping) {peerIsTyping = true; setState (() {messages.insert (0, ChatItem (Typ: Bubbles.isTyping,),};} );}} else {print ('sollte Peer-Typing entfernen'); peerIsTyping = false; _removeMessageOfType (Bubbles.isTyping);}});

Wenn ein Benutzer eine Nachricht hochlädt, werden drei Schreibvorgänge ausgeführt - in den Chat-Daten-Bucket, den DM-Daten-Bucket des Benutzers und den DM-Daten-Bucket des Peers:

Zukunft uploadMessage ({String content, User peer,}) async {final timestamp = Timestamp.now ();
  final messageRef = shared.collection ('chats'). document (chatId) .collection ('messages') .document ();
final selfRef = userRef (auth.uid) .collection ('chats'). document (peer.uid); final peerRef = userRef (peer.uid) .collection ('chats'). document (auth.uid); final selfMap = auth.user.toMap (); final peerMap = peer.toMap (); endgültige Nutzlast = {'sender_id': auth.uid, 'timestamp': timestamp, 'content': content, 'type': 'text',}; final batch = shared.batch (); /// Chat message ref batch.setData (messageRef, payload); /// Mein DM-Chat ref batch.setData (selfRef, {'is_persisted': true, 'last_checked': payload, 'last_checked_timestamp': timestamp, 'user': peerMap,}, merge: true); /// Peer DM Chat ref batch.setData (peerRef, {'is_persisted': true, 'last_checked': Nutzlast, 'last_checked_timestamp': Zeitstempel, 'user': selfMap,}, merge: true); return batch.commit (); }}

Abhängig davon, wie Sie Ihre Sicherheitsregeln festlegen, müssen Sie möglicherweise den dritten Schreibvorgang mithilfe einer Cloud-Funktion ausführen. Diese Schreibvorgänge bieten tatsächlich einen zusätzlichen Vorteil: Die Benutzerdaten im DM-Daten-Bucket werden bei jedem Senden einer Nachricht automatisch aktualisiert. Daher müssen wir möglicherweise keine Fan-Out-Cloud-Operation schreiben, die Benutzerdaten jedes Mal aktualisiert, wenn sich das Profil einer Person ändert.

Geschichten

Geschichten sind wahrscheinlich die schwierigste Funktion, die implementiert werden muss. Abgesehen von der wirklich komplexen Benutzeroberfläche benötigen wir auch eine Möglichkeit, die Geschichten zu verfolgen, die ein Benutzer gesehen hat. Darüber hinaus gibt es einige wichtige Unterschiede zwischen Posts und Momenten, auf die ich später noch eingehen werde.

Es gibt 3 Haupt-Widgets im Spiel:

Inline-Geschichten - Dies ist das Widget, das Sie über dem Feed-Bildschirm sehen. Der Hauptzweck besteht darin zu zeigen, ob die Benutzer, denen Sie folgen, in den letzten 24 Stunden mindestens eine Story hochgeladen haben.

Geschichten werden auf ähnliche Weise mithilfe eines Fan-Out-Ansatzes in den Feed eines Benutzers geschrieben. Wenn ein Benutzer eine Story hochlädt, wird eine Cloud-Funktion ausgelöst und schreibt die neuesten Story-Daten in den Story-Feed jedes Followers:

/// Dies löst eine Fan-Out-Cloud-Funktion aus. Future uploadStory ({@required DocumentReference storyRef, @required String url}) async {return await storyRef.setData ({'timestamp': Timestamp.now (), 'url': url , 'Uploader': auth.user.toMap (),}); }}

Die Benutzer-ID des Uploaders wird als Dokument-ID verwendet. Dies bedeutet, dass die Sammlung nur ein Dokument enthalten kann, das sich auf einen Uploader bezieht.

Geschichten speisen Daten-Bucket

Das Dokument enthält den Zeitstempel der neuesten Story des Uploaders, mit der wir Daten für das Widget abfragen.

Zukunft > getStoriesOfFollowings () async {final now = Timestamp.now (); final todayInSeconds = now.seconds; final todayInNanoSeconds = now.nanoseconds; /// vor 24 Stunden seit jetzt final cutOff = Timestamp (todayInSeconds - 86400, todayInNanoSeconds); letzte Abfrage = myProfileRef .collection ('story_feed') .where ('Zeitstempel', isGreaterThanOrEqualTo: cutOff); final snap = warte auf query.getDocuments (); return snap.documents.map ((doc) => UserStory.fromDoc (doc)). toList (); }}

Wir verwenden getDocuments (), ohne ein Limit festzulegen, um alle Benutzer abzurufen, die kürzlich eine Story gepostet haben. Dies ist der erste große Unterschied - im Gegensatz zu Posts paginieren wir das Abrufen von Geschichten nicht.

Es ist ratsam, die Anzahl der Benutzer, denen wir folgen können, wie bei Instagram, streng zu begrenzen, um Missbrauch zu verhindern.

Nachdem wir die neuesten User Storys haben, müssen wir mithilfe eines Rings um den Avatar des Uploaders angeben, welche Storys Storys enthalten, die der Benutzer zuvor noch nicht gesehen hat. Wenn Sie für diesen Indikatorring keinen Farbverlauf verwenden möchten, können Sie einfach einen Stapel mit einem CircularProgressIndicator mit einem etwas größeren Radius als der Avatar verwenden:

CircularProgressIndicator (valueColor: AlwaysStoppedAnimation (yourColor),),

Sie müssen einen Stream der User Stories verwalten, die Sie in den letzten 24 Stunden angesehen haben:

Strom seeStoriesStream () => myProfileRef .collection ('Seen_stories') .document ('list') .snapshots ();

Dieser Stream wird als einzelnes Dokument verwaltet, wobei jedes Feld einem Uploader entspricht und der Wert der Zeitstempel der neuesten Story des Uploaders ist, den der Benutzer angezeigt hat:

Beachten Sie, dass für jedes Firestore-Dokument eine Größenbeschränkung von 1 MB gilt. Um diese Grenze zu erreichen, muss ein Benutzer jedoch an einem einzigen Tag Geschichten von Zehntausenden verschiedener Benutzer gesehen haben, was äußerst unwahrscheinlich ist. Dies liegt daran, dass wir jedes Mal, wenn das Dokument aktualisiert wird, einen kleinen Frühjahrsputz durchführen, um Daten zu entfernen, die älter als ein Tag sind.

Zukünftige updateSeenStories (Karte data) async {final now = Timestamp.now (); final todayInSeconds = now.seconds; final todayInNanoSeconds = now.nanoseconds; /// vor 24 Stunden seit jetzt final cutOff = Timestamp (todayInSeconds - 86400, todayInNanoSeconds); final ref = myProfileRef.collection ('found_stories'). document ('list'); final doc = warte auf ref.get (); letzte Geschichten = doc.data ?? {}; /// Alte Daten entfernen Stories.removeWhere ((k, v) => v.seconds 

Wir verwenden den Stream, um den StoryState einer bestimmten UserStory abzurufen, in dem der Status nicht angezeigt, gesehen oder nicht angezeigt werden kann. Fügen Sie den Status in ein StoryAvatar-Widget ein, damit es einen farbigen / Verlaufsring anzeigt, wenn es nicht angezeigt wird, oder einen dünnen grauen Kreis, wenn es angezeigt wird.

/// Die ListView im Inline Stories-Widget hat eine horizontale Bildlaufrichtung
StreamBuilder zurückgeben (Stream: Repo.seenStoriesStream (), Builder: (Kontext, Snapshot) {if (! snapshot.hasData) return LoadingIndicator (); final foundStories = snapshot.data.data ?? {}; return ListView.builder (...
scrollDirection: Axis.horizontal, itemCount: widget.userStories? .length ?? 0, itemBuilder: (Kontext, Index) {final userStory = widget.userStories [index]; final Timestamp foundStoryTimestamp = foundStories [userStory.uploader.uid]; final storyState = userStory.lastTimestamp == null? StoryState.none: foundStoryTimestamp == null? StoryState.unseen: foundStoryTimestamp.seconds 

Diese updateSeenStories-Funktion wird im nächsten Widget aufgerufen, zu dem wir übergehen werden.

Story PageView - Dies ist das Widget, das geöffnet wird, wenn ein Benutzer auf einen Benutzer im Inline Stories-Widget tippt. Der Großteil der Stories-Erfahrung liegt in diesem Widget.

Ein weiterer großer Unterschied ist, dass Geschichten nur geladen werden, wenn sie benötigt werden. Im Gegensatz zu Posts, die immer sichtbar sind, werden Storys nur angezeigt, wenn der Benutzer die Story eines Benutzers öffnet oder zur Story eines anderen Benutzers wischt. Dies ist das Verhalten auf Instagram, bei dem eine Fortschrittsanzeige angezeigt wird, bevor Storys geladen werden.

Die Story-Seitenansicht wird wie in Instagram modal angezeigt. Wir rufen die Methode showModalBottomSheet auf und setzen die folgenden Felder, um den gesamten Bildschirm auszufüllen:

isScrollControlled: true, useRootNavigator: true,

Auf diese Weise können wir die StoryPageView durch Wischen nach unten schließen, dank des in BottomSheet integrierten Verhaltens.

Ich stellte jedoch fest, dass die SafeArea unabhängig von meiner Tätigkeit nicht respektiert wird. Daher musste ich manuell eine obere Auffüllung angeben, damit sich der Story-Header nicht mit den Überlagerungen der System-Benutzeroberfläche überschneidet.

Die Datei enthält die folgenden Methoden zum Navigieren durch die Seitenansicht:

Mit previousPage () und nextPage () kann der PageView'sPageController zwischen Seiten mit Animation wechseln. Wir legen die Startseite des Controllers in initState () fest, basierend darauf, welcher Benutzeravatar im InlineStories-Widget gedrückt wurde.

_pop () wird aufgerufen, wenn der Benutzer die Abbrechen-Taste in der oberen rechten Ecke drückt oder automatisch, wenn die letzte Story der letzten Seite in der Seitenansicht beendet ist. Es wird einfach Navigator.of (context) .pop () aufgerufen, wodurch das BottomSheet geschlossen wird.

Bevor wir jedoch StoryPageView schließen, müssen wir unser Firestore-Dokument "found_stories" aktualisieren, wenn der Benutzer neue Storys gesehen hat. Dies erreichen wir, indem wir Repo.updateSeenStories (Map) aufrufen Daten) in der Methode dispose (). Wir erhalten die erforderlichen Daten zum Hochladen aus dem onMomentChanged (int) -Rückruf von StoryView.

Jede Seite in der Seitenansicht ist eine StoryView, das Widget, das für die Anzeige der Geschichten, die Verfolgung neuer Geschichten, die der Benutzer gesehen hat, die Bestimmung der zuerst abzuspielenden Geschichte und die Angabe, wann zu den Geschichten eines anderen Benutzers gewechselt werden soll.

Das StoryView selbst ist ein Stapel-Widget, das ein anderes PageView enthält, das die Story-Bilder mit einem MomentView durchläuft.

Ein Stapel-Widget wird verwendet, um den Benutzerkopf, die Fortschrittsbalkenanzeige und unsichtbare Gestenerkenner über der Seitenansicht zu überlagern.

Die Fortschrittsanzeige verfolgt den aktuellen Story-Fortschritt und zeigt an, wie viele Story-Teile jeder Benutzer hochgeladen hat. Jedes Stück wird als Moment bezeichnet und enthält die Medien-URL, das Upload-Datum und die Anzeigedauer.

Jede StoryView enthält einen Animations-Controller, der die automatische Wiedergabe von Storys übernimmt. Wir setzen die Dauer des Controllers in initState auf die Dauer des aktuellen Moments und setzen sie in _play () fort.

controller = AnimationController (vsync: this, duration: story.moments [momentIndex] .duration,) .. addStatusListener ((status) {if (status == AnimationStatus.completed) {switchToNextOrFinish ();}}); _abspielen();

Die _play-Funktion setzt den Animationscontroller nur fort, wenn das Bild des Augenblicks geladen wurde:

/// Setzt den Animationscontroller fort /// setzt voraus, dass der aktuelle Moment geladen wurde void _play () {if (story == null || widget.story == null) return; if (story.moments.isEmpty) return; /// wenn momentIndex nicht im Bereich liegt (aufgrund von Löschung) if (momentIndex> story.moments.length - 1) return; if (story.moments [momentIndex] .isLoaded) controller.forward (); }}

Das oberste Widget im Stapel ist ein Widget, das Benutzergesten erkennt, mit denen wir Storys anhalten, fortsetzen und wechseln:

Positioniert (oben: 120, links: 0, rechts: 0, Kind: GestureDetector (Verhalten: HitTestBehavior.opaque, onTapDown: onTapDown, onTapUp: onTapUp, onLongPress: onLongPress, onLongPressUp: onLongPressEnd,),),

onTapDown stoppt den Animations-Controller, während onTapUp basierend auf der horizontalen Position der Benutzergeste entscheidet, ob die nächste oder vorherige Story aufgerufen werden soll. onLongPress stoppt auch den Animationscontroller, während StoryView in einen Vollbildmodus versetzt wird, indem die Überlagerungen (Fortschrittsbalken, Benutzerkopfzeile usw.) ausgeblendet werden. Die Überlagerungen werden in ein AnimatedOpacity-Widget eingeschlossen, das den Wert von isInFullscreenMode abhört. onLongPressEnd setzt den isInFullscreenMode auf false zurück und setzt den Animationscontroller fort:

/// Legt das Verhältnis der linken und rechten tippbaren Teile /// des Bildschirms fest: links zum Zurückschalten, rechts zum Vorwärtsschalten des letzten DoppelmomentsSwitcherFraction = 0,26;
onTapDown (Details zu TapDownDetails) {controller.stop (); } onTapUp (Details zu TapUpDetails) {final width = MediaQuery.of (context) .size.width; if (details.localPosition.dx  isInFullscreenMode = true); } onLongPressEnd () {print ('onlongpress end'); setState (() => isInFullscreenMode = false); _abspielen(); }}

In switchToNextOrFinish prüfen wir, ob der aktuelle Moment der letzte Moment ist. Wenn dies der Fall ist, rufen wir widget.onFlashForward auf, wodurch die StoryPageView ausgelöst wird, eine völlig neue StoryView zu laden, die die Story des nächsten Benutzers enthält. Wenn dies nicht der letzte Moment ist, weisen wir den Seitencontroller von StoryView an, zum nächsten Bild zu springen. Wir setzen auch den Animations-Controller zurück und setzen ihn fort, wenn das nächste Bild geladen wird. switchToPrevOrFinish folgt der gleichen Idee.

switchToNextOrFinish () {controller.stop (); if (momentIndex + 1> = story.moments.length) {widget.onFlashForward (); } else {controller.reset (); setState (() {momentIndex + = 1; _momentPageController.jumpToPage (momentIndex);}); controller.duration = story.moments [momentIndex] .duration; _abspielen(); widget.onMomentChanged (momentIndex); }} switchToPrevOrFinish () {controller.stop (); if (momentIndex - 1 <0) {widget.isFirstStory? onReset (): widget.onFlashBack (); } else {controller.reset (); setState (() {momentIndex - = 1; _momentPageController.jumpToPage (momentIndex);}); controller.duration = story.moments [momentIndex] .duration; _abspielen(); widget.onMomentChanged (momentIndex); }}

Ich habe das Paket flutter_instagram_stories als Ausgangspunkt verwendet und es für meine App stark überarbeitet. Die gesamte Story-Erfahrung ist eine PageView (StoryView) in einer PageView (StoryPageView) in einer ListView (Inline Stories).

Es gibt viele andere Dinge, über die ich nicht gesprochen habe, wie den Aktivitätsbildschirm, den Editorbildschirm, den Erkundungsbildschirm, den Kommentarbildschirm, den Datenschutz des Kontos, Erwähnungen, Regex, die Verwendung eines eindeutigen Benutzernamens zur Authentifizierung und vieles mehr.

Ich möchte auch darauf hinweisen, dass Firebase keine perfekte Lösung ist, wenn Sie ein vollständiges Instagram-Erlebnis erstellen möchten. Dies geht auf die eingeschränkten Abfragefunktionen von Firestore zurück, und ich muss noch super komplexe Abfragen wie "Konten abrufen, die den von Ihnen verfolgten Konten ähnlich sind" herausfinden.