Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: nav stack back native #2656

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/app/feature/nav-stack/nav-stack-back.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { computed, effect, Injectable } from "@angular/core";
import { NavStackService } from "./nav-stack.service";
import { App } from "@capacitor/app";
import { Capacitor, PluginListenerHandle } from "@capacitor/core";

@Injectable({ providedIn: "root" })
/**
* Utility service to help manage intercepting back button presses when working with nav stacks
* Currently just supports hardware back button press on android, used to dismiss top nav stack
*/
export class NavStackBackService {
/** Track whether navStacks are currently open or not to enable back interception */
private navStacksOpen = computed(() => this.navStackService.openNavStacks().length > 0);

private backButtonListener: PluginListenerHandle;

constructor(private navStackService: NavStackService) {
effect(async () => {
const platform = Capacitor.getPlatform();
if (platform !== "android") return;

if (this.navStacksOpen()) {
await this.addBackButtonListener();
} else {
await this.removeBackButtonListener();
}
});
}

private async addBackButtonListener() {
const listener = await App.addListener("backButton", async (e) => {
await this.navStackService.closeTopNavStack();
});
this.backButtonListener = listener;
}

private async removeBackButtonListener() {
if (this.backButtonListener) {
await this.backButtonListener.remove();
this.backButtonListener = undefined;
}
}
}
8 changes: 7 additions & 1 deletion src/app/feature/nav-stack/nav-stack.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@ import { TemplateComponentsModule } from "../../shared/components/template/templ
import { TemplateActionRegistry } from "src/app/shared/components/template/services/instance/template-action.registry";
import { NavStackService } from "./nav-stack.service";
import { NavStackActionFactory } from "./nav-stack.actions";
import { NavStackBackService } from "./nav-stack-back.service";

@NgModule({
declarations: [NavStackComponent],
imports: [CommonModule, IonicModule, TemplateComponentModule, TemplateComponentsModule],
})
export class NavStackModule {
constructor(templateActionRegistry: TemplateActionRegistry, navStackService: NavStackService) {
constructor(
templateActionRegistry: TemplateActionRegistry,
navStackService: NavStackService,
// include navStackBack service to enable back button management
navStackBackService: NavStackBackService
) {
const { nav_stack } = new NavStackActionFactory(navStackService);
templateActionRegistry.register({ nav_stack });
}
Expand Down
14 changes: 7 additions & 7 deletions src/app/feature/nav-stack/nav-stack.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe("NavStackService", () => {
const modalSpy = await pushNavStack();

expect(modalSpy.present).toHaveBeenCalled();
expect(service["openNavStacks"].length).toEqual(1);
expect(service.openNavStacks().length).toEqual(1);
});

it("tracks stack index as attributes", async () => {
Expand All @@ -82,17 +82,17 @@ describe("NavStackService", () => {
const modalSpy1 = await pushNavStack();
const modalSpy2 = await pushNavStack();

expect(service["openNavStacks"].length).toEqual(2);
expect(service.openNavStacks().length).toEqual(2);
expect(modalSpy1.getAttribute("data-nav-stack-index")).toEqual("0");
expect(modalSpy2.getAttribute("data-nav-stack-index")).toEqual("1");
});

it("should remove the modal from service['openNavStacks'] on dismissal", async () => {
it("should remove the modal from service.openNavStacks on dismissal", async () => {
const modalSpy = await pushNavStack();
await service.closeTopNavStack();

expect(modalSpy.dismiss).toHaveBeenCalled();
expect(service["openNavStacks"].length).toBe(0);
expect(service.openNavStacks().length).toBe(0);
});

it("should close the top modal in the stack", async () => {
Expand All @@ -103,8 +103,8 @@ describe("NavStackService", () => {

expect(modalSpy1.dismiss).not.toHaveBeenCalled();
expect(modalSpy2.dismiss).toHaveBeenCalled();
expect(service["openNavStacks"].length).toBe(1);
expect(service["openNavStacks"][0]).toBe(modalSpy1);
expect(service.openNavStacks().length).toBe(1);
expect(service.openNavStacks()[0]).toBe(modalSpy1);
});

it("should close all modals in the stack", async () => {
Expand All @@ -115,6 +115,6 @@ describe("NavStackService", () => {

expect(modalSpy1.dismiss).toHaveBeenCalled();
expect(modalSpy2.dismiss).toHaveBeenCalled();
expect(service["openNavStacks"].length).toBe(0);
expect(service.openNavStacks().length).toBe(0);
});
});
59 changes: 43 additions & 16 deletions src/app/feature/nav-stack/nav-stack.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Injectable } from "@angular/core";
import { Injectable, signal } from "@angular/core";
import { ModalController } from "@ionic/angular";
import { INavStackConfig, NavStackComponent } from "./components/nav-stack/nav-stack.component";
import { SyncServiceBase } from "src/app/shared/services/syncService.base";
Expand All @@ -9,7 +9,8 @@ interface NavStackModal extends HTMLIonModalElement {}
providedIn: "root",
})
export class NavStackService extends SyncServiceBase {
private openNavStacks: HTMLIonModalElement[] = [];
public openNavStacks = signal<HTMLIonModalElement[]>([]);
private readonly MAX_NAV_STACKS = 10;

constructor(private modalCtrl: ModalController) {
super("navStack");
Expand All @@ -21,18 +22,25 @@ export class NavStackService extends SyncServiceBase {
* Await and remove from openNavStacks array on dismiss
*/
public async pushNavStack(navStackConfig: INavStackConfig) {
if (this.openNavStacks().length >= this.MAX_NAV_STACKS) {
console.warn(`[NAV STACK] Maximum number of nav stacks reached: ${this.MAX_NAV_STACKS}`);
return null;
}
const modal = await this.createNavStackModal(navStackConfig);
await this.presentAndTrackModal(modal);
return modal;
}

public async closeAllNavStacks() {
await Promise.all(this.openNavStacks.map(async (navStack) => await navStack.dismiss()));
// Close nav-stacks in reverse order
for (let index = this.openNavStacks().length - 1; index >= 0; index--) {
await this.closeNavStack(index);
}
}

public async closeTopNavStack() {
if (this.openNavStacks.length === 0) return;
await this.closeNavStack(this.openNavStacks.length - 1);
if (this.openNavStacks().length === 0) return;
await this.closeNavStack(this.openNavStacks().length - 1);
}

/**
Expand All @@ -49,26 +57,45 @@ export class NavStackService extends SyncServiceBase {
}

private async presentAndTrackModal(modal: NavStackModal) {
const navStackIndex = this.openNavStacks.length;
const navStackIndex = this.openNavStacks().length;
modal.setAttribute("data-nav-stack-index", navStackIndex.toString());
modal.style.setProperty("--nav-stack-index", navStackIndex.toString());

// Remove array entry whenever modal is dismissed
modal.onWillDismiss().then(() => {
const index = this.getNavStackIndex(modal);
if (index === -1) return;
this.openNavStacks.splice(index, 1);
});
// Handle nav stack dismissal here (whether programmatically from service or from nav-stack component)
modal.onWillDismiss().then(() => this.handleNavStackDismissal(modal));

this.addNavStackToArray(modal);
await modal.present();
this.openNavStacks.push(modal);
}

private handleNavStackDismissal(modal: NavStackModal) {
const index = this.getNavStackIndex(modal);
if (index === -1) return;
this.removeNavStackFromArray(index);
}

private getNavStackIndex(modalElement: HTMLIonModalElement) {
return this.openNavStacks.indexOf(modalElement);
return this.openNavStacks().indexOf(modalElement);
}

/**
* Programmatically dismiss a nav-stack. Handling removing from openNavStacks array is done elsewhere
* to handle both programmatic dismissal and dismissal from nav-stack component (e.g. via close button)
*/
private async closeNavStack(index: number) {
await this.openNavStacks[index].dismiss();
this.openNavStacks.splice(index, 1);
const modal = this.openNavStacks()[index];
if (modal) {
await modal.dismiss();
}
}

private addNavStackToArray(modal: NavStackModal) {
// use array destructure to create new object
this.openNavStacks.update((stacks) => [...stacks, modal]);
}

private removeNavStackFromArray(index: number) {
// use filter instead of splice to avoid modifying original object
this.openNavStacks.update((stacks) => stacks.filter((v, i) => i !== index));
}
}
Loading