UNPKG

@nstudio/nativescript-coachmarks

Version:

Display user coach marks with a couple of shape cutouts over an existing UI for NativeScript.

483 lines 21.6 kB
import { Observable, Frame, Screen, Color, Utils, View, Label, ImageSource, Image, AbsoluteLayout } from '@nativescript/core'; const arrows = {}; export class CoachMark { constructor(model) { if (model) { for (let key in model) { this[key] = model[key]; } } } } CoachMark.SHAPES = { DEFAULT: 0, CIRCLE: 1, SQUARE: 2, }; CoachMark.LABEL_POSITIONS = { BOTTOM: 0, LEFT: 1, TOP: 2, RIGHT: 3, RIGHT_BOTTOM: 4, }; CoachMark.LABEL_ALIGNMENTS = { CENTER: 0, LEFT: 1, RIGHT: 2, }; export class CoachMarks { constructor() { this.nextView = 0; } setStyle(style) { if (this._showCase) { // this._showCase.setStyle(style); } } setTitle(title) { if (this._showCase) { // this._showCase.setContentTitle(title); } } setCaption(caption) { if (this._showCase) { // this._showCase.setContentText(caption); } } setButton(buttonText) { if (this._showCase) { // this._showCase.setButtonText(buttonText); } } static start(marks, options, instance) { const coachmarks = new CoachMarks(); coachmarks.startMarks(marks, options, instance); } startMarks(marks, options, instance) { this.marks = marks; if (CoachMarks.DEBUG) console.log('CoachMarks start...'); if (CoachMarks.DEBUG) { console.log(`Total marks: ${marks.length}`); } const targets = new java.util.ArrayList(marks.length); const view = Frame.topmost()?.android?.rootViewGroup || Frame.topmost()?.currentPage?.android; const activity = Utils.android.getCurrentActivity(); let container; if (view) { // !view.hasWindowFocus?.() showing a modal w/o frame // !activity.hasWindowFocus?.() showing a modal with frame if (!view.hasWindowFocus?.() || !activity.hasWindowFocus?.()) { const fragments = activity instanceof androidx.fragment.app.FragmentActivity ? activity?.getSupportFragmentManager()?.getFragments() : activity?.getFragmentManager()?.getFragments(); const count = fragments?.size(); const last = count - 1; if (last !== -1) { const dialog = fragments.get(last); const dialogView = dialog?.getView?.(); container = dialogView?.getParent?.() ?? dialogView; } } } const that = instance ? new WeakRef(instance) : null; for (let i = 0; i < marks.length; i++) { let build; const mark = marks[i]; if (CoachMarks.DEBUG) console.log(`Setting up mark --`); let target = new com.takusemba.spotlight.Target.Builder(); if (mark?.position && 'x' in mark.position && 'y' in mark.position) { target.setAnchor(mark.position.x, mark.position.y); } if (mark?.view) { mark.view instanceof android.view.View ? target.setAnchor(mark.view) : target.setAnchor(mark.view.nativeView); } let width = 100; let height = 100; if (mark?.position && 'width' in mark.position && 'height' in mark.position) { width = mark.position.width; height = mark.position.height; } else if (mark?.view) { width = mark.view.getMeasuredWidth(); height = mark.view.getMeasuredHeight(); } let shape; switch (mark.shape) { case 1: shape = new com.takusemba.spotlight.shape.Circle(height / 2); break; case 2: shape = new com.takusemba.spotlight.shape.RoundedRectangle(height, width, 0); break; default: shape = new com.takusemba.spotlight.shape.RoundedRectangle(height, width, 5 * Screen.mainScreen.scale); break; } target = target.setShape(shape); //target.setEffect(new com.takusemba.spotlight.effet.RippleEffect(100, 200, color.argb)); const overlay = new AbsoluteLayout(); const lblCaption = new Label(); lblCaption.verticalAlignment = 'top'; lblCaption.horizontalAlignment = 'left'; lblCaption.text = mark.caption; lblCaption.textAlignment = 'center'; lblCaption.color = (options.lblTextColor ?? new Color('white')); overlay.addChild(lblCaption); const skipButton = new Label(); const continueLabel = new Label(); overlay.on(View.layoutChangedEvent, (args) => { let x = 0; let y = 0; const scale = Screen.mainScreen.scale; const anchor = build.getAnchor(); // anchor.x - (width / 2) gives us the correct x position // anchor.y - (height / 2) gives us the correct y position const position = { x: anchor.x - width / 2, y: anchor.y - height / 2, }; const labelWidth = lblCaption.getMeasuredWidth(); const labelHeight = lblCaption.getMeasuredHeight(); const lblSpacing = (options.lblSpacing ?? 35) * scale; const labelMargin = 5 * scale; const bounds = { width: overlay.getMeasuredWidth(), height: overlay.getMeasuredHeight(), }; skipButton.left = (bounds.width - skipButton.getMeasuredWidth()) / Screen.mainScreen.scale; skipButton.top = (bounds.height - skipButton.getMeasuredHeight()) / Screen.mainScreen.scale; continueLabel.top = (bounds.height - continueLabel.getMeasuredHeight()) / Screen.mainScreen.scale; switch (mark.labelAlignment) { case 1: x = Math.floor(bounds.width - labelWidth - labelMargin); break; case 2: x = labelMargin; break; default: x = Math.floor((bounds.width - labelWidth) / 2.0); break; } if (mark.labelPosition === CoachMark.LABEL_POSITIONS.TOP) { y = position.y - labelHeight - labelMargin; if (mark.showArrow) { if (!('arrow-down' in arrows)) { arrows['arrow-down'] = ImageSource.fromResourceSync('arrow_down'); } const arrowImage = arrows['arrow-down']; const imageView = new Image(); imageView.verticalAlignment = 'top'; imageView.horizontalAlignment = 'left'; imageView.stretch = 'none'; imageView.width = arrowImage.width; imageView.height = arrowImage.height; imageView.imageSource = arrowImage; const translateX = x / scale; const translateY = y / scale; y -= arrowImage.height + labelMargin; imageView.translateX = translateX; imageView.translateY = translateY; overlay.addChild(imageView); } lblCaption.translateX = x / scale; lblCaption.translateY = y / scale; } else if (mark.labelPosition === CoachMark.LABEL_POSITIONS.RIGHT_BOTTOM) { y = position.y + height + lblSpacing; const bottomY = y + labelHeight + lblSpacing; if (bottomY > bounds.height) { y = position.y - lblSpacing - labelHeight; } x = position.x + width + labelMargin; if (mark.showArrow) { if (!('arrow-top' in arrows)) { arrows['arrow-top'] = ImageSource.fromResourceSync('arrow_top'); } const arrowImage = arrows['arrow-top']; const imageView = new Image(); imageView.verticalAlignment = 'top'; imageView.horizontalAlignment = 'left'; imageView.stretch = 'none'; imageView.width = arrowImage.width; imageView.height = arrowImage.height; imageView.imageSource = arrowImage; imageView.translateX = width / 2 / scale; //(width / 2 - (arrowImage.width * scale) / 2) / scale; imageView.translateY = (y - labelMargin) / scale; y += (arrowImage.height * scale) / 2; overlay.addChild(imageView); } lblCaption.translateX = x / scale; lblCaption.translateY = y / scale; } else if (mark.labelPosition === CoachMark.LABEL_POSITIONS.RIGHT) { y = position.y + height / 2 - labelHeight / 2; x = position.x + width + labelMargin; lblCaption.translateX = x / scale; lblCaption.translateY = y / scale; } else if (mark.labelPosition === CoachMark.LABEL_POSITIONS.LEFT) { y = position.y + height / 2 - labelHeight / 2; x = bounds.width - labelWidth - labelMargin - width; if (mark.showArrow) { if (!('arrow-right' in arrows)) { arrows['arrow-right'] = ImageSource.fromResourceSync('arrow_right'); } const arrowImage = arrows['arrow-right']; const imageView = new Image(); imageView.verticalAlignment = 'top'; imageView.horizontalAlignment = 'left'; imageView.stretch = 'none'; imageView.width = arrowImage.width; imageView.height = arrowImage.height; imageView.imageSource = arrowImage; const newX = bounds.width - arrowImage.width * scale - labelMargin - width; const newY = y + labelHeight / 2 - (arrowImage.height * scale) / 2; imageView.translateX = newX / scale; imageView.translateY = newY / scale; x -= arrowImage.width * scale + labelMargin; overlay.addChild(imageView); } lblCaption.translateX = x / scale; lblCaption.translateY = y / scale; } else { y = position.y + height + lblSpacing; const bottomY = y + labelHeight + lblSpacing; if (bottomY > bounds.height) { y = position.y - lblSpacing - labelHeight; } if (mark.showArrow) { if (!('arrow-top' in arrows)) { arrows['arrow-top'] = ImageSource.fromResourceSync('arrow_top'); } const arrowImage = arrows['arrow-top']; const imageView = new Image(); imageView.verticalAlignment = 'top'; imageView.horizontalAlignment = 'left'; imageView.stretch = 'none'; imageView.width = arrowImage.width; imageView.height = arrowImage.height; imageView.imageSource = arrowImage; imageView.translateX = x / scale; imageView.translateY = y / scale; y += (arrowImage.height * scale) / 2; overlay.addChild(imageView); } lblCaption.translateX = x / scale; lblCaption.translateY = y / scale; } }); overlay.on('tap', () => { if (this.events) { this._clickEvent = { eventName: 'click', object: this, data: {}, }; this.events.notify(this._clickEvent); } this._showCase?.next?.(); }); if (options.enableSkipButton ?? true) { skipButton.fontSize = 13; skipButton.height = 30; if (!options.continueLabelSize && typeof options?.continueLabelSize?.width === 'undefined') { skipButton.width = { value: 0.3, unit: '%' }; } skipButton.text = options.skipButtonText ?? 'Skip'; if ('skipButtonTextColor' in options) { if (options.skipButtonTextColor instanceof Color || typeof options.skipButtonTextColor === 'string') { skipButton.color = options.skipButtonTextColor; } } else { skipButton.color = new Color('white'); } if ('skipButtonBackgroundColor' in options) { if (options.skipButtonBackgroundColor instanceof Color || typeof options.skipButtonBackgroundColor === 'string') { skipButton.backgroundColor = options.skipButtonBackgroundColor; } } if (options.skipButtonOffset && 'x' in options.skipButtonOffset) { skipButton.translateX = options.skipButtonOffset.x; } if (options.skipButtonOffset && 'y' in options.skipButtonOffset) { skipButton.translateY = options.skipButtonOffset.y; } skipButton.horizontalAlignment = 'right'; skipButton.verticalAlignment = 'bottom'; skipButton.textAlignment = 'center'; skipButton.on('tap', () => { if (CoachMarks.DEBUG) { console.log('coachMarks skip button clicked.'); } if (this.events) { this.events.notify(this._skipEvent); } this._showCase?.finish?.(); }); overlay.addChild(skipButton); } // continue label if (i === 0 && (options.enableContinueLabel ?? true)) { continueLabel.fontSize = 13; continueLabel.height = 30; if (options.enableSkipButton ?? true) { continueLabel.width = { value: 0.7, unit: '%' }; } else { continueLabel.width = { value: 1, unit: '%' }; } if ('continueLabelSize' in options) { if ('width' in options.continueLabelSize) { continueLabel.width = options.continueLabelSize.width; } if ('height' in options.continueLabelSize) { continueLabel.height = options.continueLabelSize.height; } } continueLabel.text = options.continueLabelText ?? 'Tap to continue'; if ('continueLabelTextColor' in options) { if (options.continueLabelTextColor instanceof Color || typeof options.continueLabelTextColor === 'string') { continueLabel.color = options.continueLabelTextColor; } } if ('continueLabelBackgroundColor' in options) { if (options.continueLabelBackgroundColor instanceof Color || typeof options.continueLabelBackgroundColor === 'string') { continueLabel.backgroundColor = options.continueLabelBackgroundColor; } } continueLabel.horizontalAlignment = 'left'; continueLabel.verticalAlignment = 'bottom'; continueLabel.textAlignment = 'center'; switch (options.continueLocation) { case 0: continueLabel.verticalAlignment = 'top'; break; case 1: continueLabel.verticalAlignment = 'middle'; break; } if (options.continueLabelOffset && 'x' in options.continueLabelOffset) { continueLabel.translateX = options.continueLabelOffset.x; } if (options.continueLabelOffset && 'y' in options.continueLabelOffset) { continueLabel.translateY = options.continueLabelOffset.y; } continueLabel.on('tap', () => { this._showCase?.next?.(); }); overlay.addChild(continueLabel); } overlay._setupAsRootView(activity); overlay._setupUI(activity); overlay.callLoaded(); target.setOnTargetListener(new com.takusemba.spotlight.OnTargetListener({ onStarted() { }, onEnded() { if (i === marks.length - 1) { if (CoachMarks.DEBUG) { console.log('coachMarks is about to cleanup, prepare any final adjustments if needed.'); } const owner = that?.deref(); if (owner?.events) { owner.events.notify(owner._willCleanupEvent); } } }, })); build = target.setOverlay(overlay.nativeView).build(); targets.add(build); } this._targets = targets; const background = options.maskColor instanceof Color ? options.maskColor : new Color('rgba(0, 0, 0, 0.5)'); let showCaseBuilder = new com.takusemba.spotlight.Spotlight.Builder(activity) .setBackgroundColor(background.argb) .setDuration((options?.animationDuration ?? 0.3) * 1000) .setTargets(targets) .setOnSpotlightListener(new com.takusemba.spotlight.OnSpotlightListener({ onStarted() { }, onWillNavigate(index) { if (CoachMarks.DEBUG) { console.log(`will navigate to index: ${index}`); } const owner = that?.deref(); if (owner?.events) { owner._willNavigateEvent.data = { instance: owner, index, }; owner.events.notify(owner._willNavigateEvent); } }, onDidNavigate(index) { if (CoachMarks.DEBUG) { console.log(`navigated to index: ${index}`); } const owner = that?.deref(); if (owner?.events) { owner._navigateEvent.data = { instance: owner, index, }; owner.events.notify(owner._navigateEvent); } }, onEnded() { if (CoachMarks.DEBUG) { console.log('coachMarks did cleanup, clear your instances if you have any'); } const owner = that?.deref(); if (owner?.events) { owner.events.notify(owner._cleanupEvent); } }, })); if (container) { showCaseBuilder = showCaseBuilder.setContainer(container); } this._showCase = showCaseBuilder.build(); this._showCase.start(); } initEvents() { this.events = new Observable(); this._willNavigateEvent = { eventName: 'willNavigate', object: this, data: {}, }; this._navigateEvent = { eventName: 'navigate', object: this, data: {}, }; this._clickEvent = { eventName: 'click', object: this, data: {}, }; this._cleanupEvent = { eventName: 'cleanup', object: this, data: {}, }; this._willCleanupEvent = { eventName: 'willCleanup', object: this, data: {}, }; this._skipEvent = { eventName: 'skip', object: this, data: {}, }; } } CoachMarks.DEBUG = false; CoachMarks.CONTINUE_LOCATIONS = { TOP: 0, CENTER: 1, BOTTOM: 2, }; //# sourceMappingURL=index.android.js.map