I'm trying to "copy" this stackblitz in order to get my staggered animation to work but my data structure is more complex and despite my best efforts to keep it as close as possible, I can't get it to work.
I have a DataHandlerService that periodically sends data requests to the database and stores the results in an internal cache. There are multiple DataTypes to request data from but all data is derived from an abstract parent DataEntry class.
export enum DataType { AREAS = 'areas',
SPECIES = 'species' }
export abstract class DataEntry {
public id: string;
public title: string;
public type: DataType;
constructor(id: string, title: string, type: DataType) {
this.id = id;
this.title = title;
this.type = type;
}
}
export class Record extends DataEntry {
public subtitle?: string;
public img?: FileData;
public map?: MapData;
public content: string;
public children: Record[];
constructor(id: string,
title: string,
type: DataType,
content: string,
subtitle?: string,
img?: FileData,
map?: MapData,
children?: Record[]) {
super(id, title, type);
this.subtitle = subtitle;
this.img = img;
this.map = map;
this.content = content;
this.children = children || [];
}
}
FileData and MapData are irrelevant at this point and they're empty in my test data anyway. Honestly, even Record isn't relevant to the issue but it shows up in the code further down and an example of the derivation can't hurt.
export class DataHandlerService implements OnDestroy {
private _intervalPeriod: number = 1000 * 60 * 10; // 10 minute interval
private _intervalSubscription!: Subscription;
private _dbFacade: DbFacadeService;
The DbFacadeService provides the actual access to the database with all its CRUD methods returning Promises of the respective http requests. I don't think it is relevant but let me know if it is, then I can add it.
private _dataType: DataType | undefined;
public set dataType(dataType: DataType) { this._dataType = dataType; }
The DataType must be set externally from the component before making any data requests. Every page component only uses data of a single type, but each of a different one. It is required for the database service to request the proper data.
private _cache: Map<DataType, Map<string, DataEntry>>;
private _cacheSubject!: BehaviorSubject<DataEntry[]>;
public get cache(): Observable<DataEntry[]> {
this._cacheSubject?.complete();
this._cacheSubject = new BehaviorSubject<DataEntry[]>([]);
return this._cacheSubject.asObservable();
}
The _cache holds the data from the database, maps of data entries accessed via their id (which is an ObjectId from mongodb, i.e. a string in the app), wrapped in a map of data types, so there is a map for each data type. The nested map structure is a remnant from earlier, not sure if the access via id is still necessary or helpful in the current situation, so this may change to Map<DataType, DataEntry[]> in the future, but I'm positive this is irrelevant.
When cache gets requested, which is supposed to happen during component initialization, i.e. once per page, a new behavior subject is created and any potential previous one is finalized.
constructor(dbFacade: DbFacadeService) {
this._dbFacade = dbFacade;
this._cache = new Map((Object.values(DataType) as DataType[]).map((type: DataType) => [type, new Map<string, DataEntry>([])]));
this._intervalSubscription = timer(0, this._intervalPeriod).subscribe(() => this.updateCache());
}
The constructor generates an empty map for each data type in the DataType enum, to ensure any potential request prior to the first database request will return an empty array instead of nothing. It also starts the interval for the database update with a first immediate call.
ngOnDestroy(): void {
this._intervalSubscription.unsubscribe();
this._cacheSubject.complete();
}
public getData(): void {
if (!this._dataType) { throw Error('Data type must be set prior to requesting data.'); }
Array.from(this._cache.get(this._dataType)?.values() || [])
.forEach((entry: DataEntry) => this.addEntry(entry));
}
private addEntry(dataEntry: DataEntry): void {
this._cacheSubject.next([...this._cacheSubject.value, dataEntry]);
}
I have read it is important to not simply pass an array to a staggered animation as that won't work, but basically every element needs to be added in its own step and the full data set be returned each time, which is also what happens in the stackblitz, thus, after setting the DataType and getting the cache, a component must first wait for the routing animation to finish, then request the data currently in the cache to be added to the subject one by one by calling getData().
private updateCache(): void {
(Object.values(DataType) as DataType[]).forEach((dataType: DataType) => {
this._dbFacade.readAll(dataType).then((entries: DataEntry[]) => {
this._cache.set(dataType, new Map<string, DataEntry>(entries.map((entry: DataEntry) => [entry.id, entry])));
if (dataType === this._dataType) {
entries.forEach((dataEntry: DataEntry) => this.addEntry(dataEntry));
}
})
})
}
}
This method runs over all entries in the DataType enum and requests all data from the database for each of them, then updates both the map cache as well as the subject for the data type currently set in the service. I am aware this will add the same data entries to the subject for each request, which will change in the future to update any existing entry instead of adding it anew. I would like to resolve the animation issue first, however.
This service is then called by the component, which I'm routing to and is displayed in the <router-outlet>. Each of them will be derived from an abstract component, which handles/offers routing navigation.
@Component({
template: ''
})
export abstract class ContentComponent {
private _navStatusService: NavStatusService;
protected get transition(): Observable<boolean> {
return this._navStatusService.transition.pipe(skipWhile(transition => transition), first());
}
The NavStatusService offers on observable transition (of a BehaviorSubject<boolean>), which is set to true when a routing animation starts, and false once that animation ends.
The transition getter returns an observable that skips every true value of the NavStatusService's transition and resolves only once when the first false value is sent, i.e. the routing animation finished. I'm not sure whether or how it is possible to combine my routing animation with the component's own animation in a complex structure of animation and child animation and thus skip the whole behavior subject waiting for the routing animation to finish before requesting data and how that would affect the staggering (because the whole data list would have already been loaded before the stagger animation would start, and with what I mentioned earlier, every data entry having to be set and received by the component individually, I have high suspicions it would cause trouble). And this works, the data from the data handler is requested after the routing animation finished, so I'm fine with this.
constructor(location: Location,
navIndexService: NavIndexService,
navStatusService: NavStatusService) {
this._navStatusService = navStatusService;
const index: number = navIndexService.getNavIndex('main', location.path());
navIndexService.navIndex.next(+index);
}
}
The NavIndexService is used to tell the direction of the routing animation, from top to bottom or vice versa depending on whether a higher or lower navigation item has been selected. I'm fairly certain none of this is relevant or interfering, as I've disabled the routing animation and was still facing the issue of the missing stagger.
The animations are stored in a class as static properties in a shared module, so every possible component could select its animations from there. Here are the relevant ones:
export class Animations {
public static StaggerChildren: AnimationTriggerMetadata =
trigger('staggerChildren',
[transition('* => *',
[query('@dropIn',
stagger(2000, animateChild()),
{ optional: true })])]);
public static DropIn: AnimationTriggerMetadata =
trigger('dropIn',
[transition(':enter',
[style({ opacity: 0, transform: 'translateY(-100%)' }),
animate('3s cubic-bezier(.35, 0, .25, 1)',
style({ opacity: 1, transform: 'translateY(0)' }))]),
transition(':leave',
[style({ opacity: 1, transform: 'translateY(0)' }),
animate('3s cubic-bezier(.25, 0, .35, 1)',
style({ opacity: 1, transform: 'translateY(-100%)' }))])]);
}
Notice the { optional: true } in the staggerChildren animation, which I have to add or receive an exception that the query can't find any elements, which apparently doesn't happen in the stackblitz, and may be the cause of the stagger not working(?).
This is then one of the pages requesting data from the data handler.
@Component({
selector: 'wb-record',
templateUrl: './record.component.html',
host: { class: 'wb-list' },
animations: [Animations.StaggerChildren, Animations.DropIn]
})
export class RecordComponent extends ContentComponent implements OnInit, OnDestroy {
private _router: Router;
private _route: ActivatedRoute;
private _paramSubscription: Subscription | undefined;
private _dataHandler: DataHandlerService;
public records: Observable<Record[]> | undefined;
public recordType: DataType | undefined;
The route this component is assigned to is { path: 'record/:type', component: RecordComponent }. The data type is taken from the route, while the router serves potential redirection to an error page.
The records is the observable taken from the data handler, cast to the child class used in this component.
constructor(router: Router,
route: ActivatedRoute,
location: Location,
navIndexService: NavIndexService,
navStatusService: NavStatusService,
dataHandler: DataHandlerService) {
super(location, navIndexService, navStatusService);
this._router = router;
this._route = route;
this._dataHandler = dataHandler;
}
ngOnInit(): void {
this._paramSubscription = this._route.url.subscribe((segments: UrlSegment[]) => this.init(segments));
}
ngOnDestroy(): void {
this._paramSubscription?.unsubscribe;
}
OnInit and OnDestroy subscribe and unsubscribe to the route url to get the data type from the route.
private init(segments: UrlSegment[]): void {
this.recordType = segments.at(-1)?.path.toLowerCase() as DataType | undefined;
if (!this.recordType) {
this._router.navigate(['/error', 404]);
return;
}
this._dataHandler.dataType = this.recordType;
this.records = this._dataHandler.cache as Observable<Record[]>;
this.transition.subscribe(() => this._dataHandler.getData());
}
}
Redirects to an error page if the record type in the url is not a valid one, otherwise sets the record type in the data handler, requests for the data handler's cache, finally subscribes to the parent's transition observable to set the data in the handler's cache observable, i.e. start the stagger animation and show the records once the routing animation finished.
<mat-accordion @staggerChildren>
<wb-record-entry *ngFor="let record of (records | async)"
[entry]="record"
@dropIn></wb-record-entry>
</mat-accordion>
And this is where the animations are set in the template. Notice that I added both parent and child animation in this template instead of the child animation being in the child component, but I've tested this in the stackblitz as well and it still works, so this is not the issue.
The current result is:
- the routing animation is started
- the
RecordComponent's init method runs getting the cache observable - the routing animation finishes
- the
DataHandlerService's getData method is called (the database request isn't finished yet, so there's no data to receive) - the routing animation is started (????). I have no idea why [@routingAnimation.start] gets called a 2nd time, but there's no actual animation running.
- the
DataHandlerServices updateCache method is called - the
DataHandlerServices addEntry method is called adding the first Record to the observable. Looking at the BehaviorSubject's value at this point, the content is correct, there's one Record element in it. - the addEntry method is called a second time adding the second Record to the observable. Again, the data in the BehaviorSubject is correct.
- the 2nd routing animation is finished (????). Again, no idea why it is called a 2nd time. But if I set a breakpoint here, the staggered animation for the Records doesn't start, yet. Not sure if it pauses because of the breakpoint basically immediately after it started running and thus there's nothing to see yet as the opacity is still 0 or whether it hasn't started at all.
- The [@dropIn] animation starts for both records at the same time and is not staggered as it should be.
I'm banging my head against this for hours now and can't figure out why it wouldn't run like in the stackblitz. I've tried to reduce my html down to a *ngFor of simple divs inside another container div, to rule out the angular material elements somehow interfering with whatever css rule or own animation they have, but this still didn't make it work.
EDIT
I've recreated the stackblitz as is and got it to work, then tried to move away from there to my current build. So I implemented this in the DataHandler to set the initial data:
private readonly _list = new BehaviorSubject<DataEntry[]>([]);
public readonly list = this._list.asObservable();
public counter = 0;
public add(dataEntry: DataEntry): void {
this._list.next([dataEntry, ...this._list.value]);
this.counter++;
}
public initObservable(): void {
for (let i = 0; i <= 4; i++) {
const counter = this.counter + 1;
let dataEntry: Record = { id: `${counter}`,
title: `Record No. ${counter}`,
type: this._dataType!,
content: '',
children: [] };
this.add(dataEntry);
}
}
Then I changed the initObservable function to get the data from my database service to this:
public initObservable(): void {
this._dbFacade.readAll(this._dataType!)
.then((entries: DataEntry[]) => {
entries.forEach((entry: DataEntry) => {
this.add(entry);
});
});
And it stopped working. It seems like the Promise I receive from the database service creates some kind of closed code structure and only resolves the code outside once it's finished, i.e. the animation only starting after both elements have been added. As to why or how, I have no idea.
I've changed the outdated Promise code of the DbFacadeService's readAll() method into one working with an rxjs observable, like this:
public readAll(dataType: DataType): Observable<DataEntry[]> {
return this._http
.get<DataEntry[]>(`${IDbFacade.BASEURL}/${dataType}`,
{ observe: 'response' })
.pipe(map((response: HttpResponse<DataEntry[]>) => {
return response.body || [];
}),
first());
But an observable doesn't fix the issue either. The only way I managed to get it to work was ditching the stagger animation entirely, then add a timer with the actual delay in my readAll()-subscription.
this._dbFacade.readAll(dataType)
.subscribe((entries: DataEntry[]) => {
timer(0, 300).pipe(take(entries.length))
.subscribe((index: number) => {
this.add(entries[index]));
});
});
While this works, it shouldn't be the solution to go for. especially since the execution of the timer appears to vary, the elements do not animate in with a steady delay, they only take at least the delayed amount of time but often longer, so it doesn't feel like a smooth inflow of elements. Stagger should work out of the box and with a whole array added at once, not only with every value on its own. This would likely also eliminate the promise/observable problem as it wouldn't be an issue anymore that the animation starts only after the promise/observable has been resolved.
Anyone knows how to properly stagger this using the actual angular animations?