@nativescript-community/ui-appbar 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.android.js CHANGED
@@ -1,11 +1,15 @@
1
- import { ActionBar, Application, Color, Page, Utils } from '@nativescript/core';
1
+ import { ActionBar, Application, Color, ContentView, FormattedString, ImageSource, Page, TextField, Utils } from '@nativescript/core';
2
2
  import { createNativeFormattedText } from '@nativescript-community/ui-core/formatted-text';
3
- import { getNativeIcon } from '@nativescript-community/ui-menu';
4
- import { AppBarBase, AppBarItem, AppPageOverlay, AppPageSearchContent, notifyBottomItemTap } from './index-common';
5
- export { AppBarItem, AppPageOverlay, AppPageSearchContent };
3
+ import { getNativeIcon, showMenu } from '@nativescript-community/ui-menu';
4
+ import { AppBarBase, AppBarBottomView, AppBarItem, AppBarSearchField, AppPageOverlay, AppPageSearchContent, notifyBottomItemTap } from './index-common';
5
+ export { AppBarBottomView, AppBarItem, AppBarSearchField, AppPageOverlay, AppPageSearchContent };
6
6
  const MATCH_PARENT = -1;
7
7
  const WRAP_CONTENT = -2;
8
8
  const COLLAPSE_MODE_PIN = 1;
9
+ const MATERIAL_NAVIGATION_ICONS = {
10
+ ic_menu_back: 'abc_ic_ab_back_material',
11
+ ic_menu_close_clear_cancel: 'abc_ic_clear_material'
12
+ };
9
13
  function getActionBarSize(context) {
10
14
  const value = new android.util.TypedValue();
11
15
  if (context.getTheme().resolveAttribute(16843499 /* android.R.attr.actionBarSize */, value, true)) {
@@ -18,7 +22,35 @@ function getMaterialAttr(name) {
18
22
  }
19
23
  /** a color of the Material theme (colorSurface, colorOnSurface...) */
20
24
  function getThemeColor(view, name, fallback) {
21
- return com.google.android.material.color.MaterialColors.getColor(view, getMaterialAttr(name), fallback);
25
+ // the theme of a view context is a copy made when the view was created: with dynamic colors it keeps the day/night colors of that time.
26
+ // The theme of the activity is the one that is applied again when the mode changes
27
+ const activity = Application.android.foregroundActivity || Application.android.startActivity;
28
+ return com.google.android.material.color.MaterialColors.getColor(activity || view.getContext(), getMaterialAttr(name), fallback);
29
+ }
30
+ let noShadowOutline;
31
+ /** a view that only takes part in the stacking order (elevation) casts no shadow */
32
+ function getNoShadowOutline() {
33
+ if (!noShadowOutline) {
34
+ // @ts-ignore abstract class: extended through the runtime
35
+ const NoShadowOutline = android.view.ViewOutlineProvider.extend({ getOutline: (_view, outline) => outline.setEmpty() });
36
+ noShadowOutline = new NoShadowOutline();
37
+ }
38
+ return noShadowOutline;
39
+ }
40
+ /** the SearchBar draws its fill in a MaterialShapeDrawable under a ripple / focus ring layer */
41
+ function findShapeDrawable(drawable) {
42
+ if (drawable instanceof com.google.android.material.shape.MaterialShapeDrawable) {
43
+ return drawable;
44
+ }
45
+ if (drawable instanceof android.graphics.drawable.LayerDrawable) {
46
+ for (let index = 0; index < drawable.getNumberOfLayers(); index++) {
47
+ const found = findShapeDrawable(drawable.getDrawable(index));
48
+ if (found) {
49
+ return found;
50
+ }
51
+ }
52
+ }
53
+ return null;
22
54
  }
23
55
  function toNativeColor(value) {
24
56
  if (!value) {
@@ -106,20 +138,33 @@ export class AppBar extends AppBarBase {
106
138
  toolbar.setOverflowIcon(wrapped);
107
139
  }
108
140
  }
109
- // the back arrow of the core is the old holo one: use the Material arrow
141
+ // the back arrow and the close icon of the core are the old holo ones: use the Material (appcompat) ones
110
142
  _updateNavigationButton() {
111
143
  // @ts-ignore
112
144
  super._updateNavigationButton();
113
145
  const nav = this.navigationButton;
114
146
  const toolbar = this.nativeViewProtected;
115
- if (nav && nav.visibility === 'visible' && nav.android?.systemIcon === 'ic_menu_back') {
147
+ const drawableName = nav?.visibility === 'visible' ? MATERIAL_NAVIGATION_ICONS[nav.android?.systemIcon] : undefined;
148
+ if (drawableName) {
116
149
  const context = this._context;
117
- const id = context.getResources().getIdentifier('abc_ic_ab_back_material', 'drawable', context.getPackageName());
150
+ const id = context.getResources().getIdentifier(drawableName, 'drawable', context.getPackageName());
118
151
  if (id) {
119
152
  toolbar.setNavigationIcon(androidx.core.content.ContextCompat.getDrawable(context, id));
120
153
  }
121
154
  }
122
155
  }
156
+ _addViewToNativeVisualTree(child, atIndex) {
157
+ // the page places them (search field, floating toolbar), not the toolbar
158
+ return child === this.searchField || child === this.bottomView || super._addViewToNativeVisualTree(child, atIndex);
159
+ }
160
+ _removeViewFromNativeVisualTree(child) {
161
+ if (child === this.searchField || child === this.bottomView) {
162
+ const nativeChild = child.nativeViewProtected;
163
+ nativeChild?.getParent()?.removeView(nativeChild);
164
+ return;
165
+ }
166
+ super._removeViewFromNativeVisualTree(child);
167
+ }
123
168
  onAppBarPropertyChanged() {
124
169
  super.onAppBarPropertyChanged();
125
170
  const page = this.page;
@@ -127,6 +172,15 @@ export class AppBar extends AppBarBase {
127
172
  page.syncAppBar();
128
173
  }
129
174
  }
175
+ // @ts-ignore
176
+ _onTitlePropertyChanged() {
177
+ // @ts-ignore
178
+ super._onTitlePropertyChanged();
179
+ const page = this.page;
180
+ if (page instanceof AppPage) {
181
+ page.syncAppBar();
182
+ }
183
+ }
130
184
  update() {
131
185
  super.update();
132
186
  const page = this.page;
@@ -134,15 +188,44 @@ export class AppBar extends AppBarBase {
134
188
  page.syncAppBar();
135
189
  }
136
190
  }
191
+ /** the toolbar is hidden while the SearchBar hosts the actions: the menu opens under the SearchBar */
192
+ getMenuAnchor() {
193
+ const page = this.page;
194
+ const searchBar = page instanceof AppPage ? page.hostingSearchBar : null;
195
+ return searchBar ? { nativeViewProtected: searchBar } : this;
196
+ }
197
+ closeSearch() {
198
+ const page = this.page;
199
+ return page instanceof AppPage ? page.closeInlineSearch() : false;
200
+ }
201
+ focusSearch() {
202
+ const page = this.page;
203
+ return page instanceof AppPage ? page.openInlineSearch() : false;
204
+ }
137
205
  _addActionItems() {
138
206
  // @ts-ignore
139
207
  super._addActionItems();
208
+ this.applyItemIcons();
140
209
  this.syncBadges();
141
- if (this.searchable && this.searchMode !== 'bar') {
210
+ if (this.searchable && this.searchMode === 'collapsible') {
142
211
  this.addSearchItem();
143
212
  }
144
213
  this.applyTints();
145
214
  }
215
+ /** core only loads string icons: an ImageSource or a FormattedString (font icon) is converted here */
216
+ applyItemIcons() {
217
+ const menu = this.nativeViewProtected.getMenu();
218
+ const items = (this.actionItems?.getVisibleItems() ?? []);
219
+ items.forEach((item) => {
220
+ const icon = item.icon;
221
+ if (icon instanceof ImageSource || icon instanceof FormattedString) {
222
+ const drawable = getNativeIcon(icon);
223
+ if (drawable) {
224
+ menu.findItem(item._getItemId())?.setIcon(drawable);
225
+ }
226
+ }
227
+ });
228
+ }
146
229
  /** the badges of the actions (a number, a text or a dot): attached once the action views exist */
147
230
  syncBadges() {
148
231
  const toolbar = this.nativeViewProtected;
@@ -243,7 +326,24 @@ export class AppPage extends Page {
243
326
  constructor() {
244
327
  super(...arguments);
245
328
  this._scrollBehavior = 'none';
329
+ // the activity handles `uiMode` itself (no recreate): the theme resolves the new colors right away, but the views keep the ones they were created with
330
+ this._appearanceListener = () => {
331
+ const bar = this.hasActionBar ? this.actionBar : null;
332
+ if (bar instanceof AppBar) {
333
+ bar.update();
334
+ }
335
+ else {
336
+ this.syncAppBar();
337
+ }
338
+ this.syncStatusBarIcons();
339
+ };
246
340
  this._searchBarSetup = false;
341
+ // the native view of a hosted view of the bar is created after the bar is added to the page
342
+ this._syncOnLoaded = (args) => {
343
+ args.object.off('loaded', this._syncOnLoaded);
344
+ this.syncAppBar();
345
+ };
346
+ this._inlineOpen = false;
247
347
  this._bottomBadges = [];
248
348
  }
249
349
  /** the CollapsingToolbarLayout of a medium / large bar */
@@ -281,6 +381,10 @@ export class AppPage extends Page {
281
381
  else if (value instanceof AppPageSearchContent) {
282
382
  this.searchContent = value;
283
383
  }
384
+ else if (value instanceof AppBarBase) {
385
+ // core only matches the constructor name 'ActionBar' (@akylas/nativescript)
386
+ this.actionBar = value;
387
+ }
284
388
  else {
285
389
  super._addChildFromBuilder(name, value);
286
390
  }
@@ -337,6 +441,7 @@ export class AppPage extends Page {
337
441
  coordinator.addView(scrim, new CoordinatorLayout.LayoutParams(MATCH_PARENT, 0));
338
442
  // the AppBarLayout is elevated (lift on scroll): a sibling is drawn below it unless it is elevated more
339
443
  scrim.setElevation(Math.round(12 * Utils.layout.getDisplayDensity()));
444
+ scrim.setOutlineProvider(getNoShadowOutline());
340
445
  this._statusScrim = scrim;
341
446
  // the full screen search of the M3 SearchBar: on top of everything, hidden until the bar is tapped
342
447
  const searchView = new com.google.android.material.search.SearchView(context);
@@ -359,8 +464,21 @@ export class AppPage extends Page {
359
464
  activity.getWindow().setSoftInputMode(this._softInputMode);
360
465
  }
361
466
  this._softInputMode = undefined;
467
+ Application.off(Application.systemAppearanceChangedEvent, this._appearanceListener);
362
468
  super.onUnloaded();
363
469
  }
470
+ /** the icons of the status bar follow the luminance of the bar drawn under it: the style of the page is not applied again by a live dark / light switch */
471
+ syncStatusBarIcons() {
472
+ const window = (Application.android.foregroundActivity || Application.android.startActivity)?.getWindow();
473
+ if (!window) {
474
+ return;
475
+ }
476
+ // without an AppBar (actionBarHidden) the status bar is over the background of the page
477
+ const bar = this.hasActionBar ? this.actionBar : null;
478
+ const color = bar instanceof AppBar ? bar.getBarColor() : (toNativeColor(this.backgroundColor) ?? getThemeColor(this.nativeViewProtected, 'colorSurface', android.graphics.Color.WHITE));
479
+ const light = androidx.core.graphics.ColorUtils.calculateLuminance(color) > 0.5;
480
+ androidx.core.view.WindowCompat.getInsetsController(window, window.getDecorView()).setAppearanceLightStatusBars(light);
481
+ }
364
482
  onLoaded() {
365
483
  super.onLoaded();
366
484
  // The root of the page pads itself with the keyboard (edge to edge): when the window also resizes for the keyboard (adjustResize)
@@ -371,6 +489,7 @@ export class AppPage extends Page {
371
489
  this._softInputMode = window.getAttributes().softInputMode;
372
490
  window.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING | (this._softInputMode & 0x0f));
373
491
  }
492
+ Application.on(Application.systemAppearanceChangedEvent, this._appearanceListener);
374
493
  // the bar draws under the status bar: the AppBarLayout pads itself with the inset
375
494
  this.androidOverflowEdge = 'top';
376
495
  // the root does not pad itself with the status bar inset anymore: the AppBarLayout (and its background) does
@@ -387,6 +506,8 @@ export class AppPage extends Page {
387
506
  this._coordinator = null;
388
507
  this._appBarLayout = null;
389
508
  this._nativeContent = null;
509
+ this._liftTarget = null;
510
+ this._liftContentListener = null;
390
511
  this._nativeOverlay = null;
391
512
  this._bottomBar = null;
392
513
  this._statusScrim = null;
@@ -458,7 +579,8 @@ export class AppPage extends Page {
458
579
  const child = this._collapsing || toolbar;
459
580
  const params = child.getLayoutParams();
460
581
  // `hide` with a scroll target: the bar follows the scroll of the list itself (see syncHide), the AppBarLayout does not scroll
461
- const scrollTargetNative = appBar?.scrollTarget?.nativeViewProtected;
582
+ const scrollTargetView = appBar?.getScrollTarget();
583
+ const scrollTargetNative = scrollTargetView?.nativeViewProtected;
462
584
  const customHide = behavior === 'hide' && !!scrollTargetNative;
463
585
  let flags = 0;
464
586
  if (behavior === 'hide' && !customHide) {
@@ -497,10 +619,7 @@ export class AppPage extends Page {
497
619
  background.setFillColor(android.content.res.ColorStateList.valueOf(barColor));
498
620
  appBarLayout.setLiftOnScrollColor(android.content.res.ColorStateList.valueOf(liftedColor));
499
621
  appBarLayout.setLiftOnScroll(behavior === 'lift');
500
- const scrollTarget = appBar?.scrollTarget?.nativeViewProtected;
501
- if (behavior === 'lift' && scrollTarget) {
502
- appBarLayout.setLiftOnScrollTargetView(scrollTarget);
503
- }
622
+ this.syncLiftTarget(appBar, behavior);
504
623
  this.setStatusScrimColor(appBarLayout.isLifted() && behavior === 'lift' ? liftedColor : barColor);
505
624
  if (!this._liftListener) {
506
625
  // the status bar area follows the color of the bar while it lifts
@@ -514,6 +633,7 @@ export class AppPage extends Page {
514
633
  this._collapsing.setContentScrimColor(behavior === 'lift' ? liftedColor : barColor);
515
634
  this._collapsing.setStatusBarScrimColor(behavior === 'lift' ? liftedColor : barColor);
516
635
  }
636
+ toolbar.setTitleTextColor(onBarColor);
517
637
  toolbar.setSubtitleTextColor(onBarColor);
518
638
  if (this._collapsing) {
519
639
  this._collapsing.setCollapsedTitleTextColor(onBarColor);
@@ -538,7 +658,7 @@ export class AppPage extends Page {
538
658
  }
539
659
  }
540
660
  this.syncTranslucentInset(appBar, overlayContent);
541
- this.syncHide(customHide ? appBar.scrollTarget : null);
661
+ this.syncHide(customHide ? scrollTargetView : null);
542
662
  this.syncBottomBar(appBar, behavior);
543
663
  this.syncSearchBar(appBar);
544
664
  }
@@ -601,17 +721,66 @@ export class AppPage extends Page {
601
721
  view.off('scroll', listener);
602
722
  };
603
723
  }
724
+ /** the first vertically scrolling view under `view`: the lift state must follow one view, else several nested scroll events disagree and the bar blinks */
725
+ findScrollingView(view) {
726
+ if (view instanceof androidx.recyclerview.widget.RecyclerView || view instanceof androidx.core.widget.NestedScrollView || view instanceof android.widget.ScrollView) {
727
+ return view;
728
+ }
729
+ if (view instanceof android.view.ViewGroup) {
730
+ for (let index = 0; index < view.getChildCount(); index++) {
731
+ const found = this.findScrollingView(view.getChildAt(index));
732
+ if (found) {
733
+ return found;
734
+ }
735
+ }
736
+ }
737
+ return null;
738
+ }
739
+ /** `lift` follows `scrollTarget` / `scrollTargetId`, by default the first scrolling view of the content (searched again when the content is laid out) */
740
+ syncLiftTarget(appBar, behavior) {
741
+ const content = this._nativeContent;
742
+ if (behavior !== 'lift') {
743
+ return;
744
+ }
745
+ const explicit = appBar?.getScrollTarget()?.nativeViewProtected;
746
+ const target = explicit ?? this.findScrollingView(content);
747
+ if (target && target !== this._liftTarget) {
748
+ this._liftTarget = target;
749
+ this._appBarLayout.setLiftOnScrollTargetView(target);
750
+ }
751
+ if (!explicit && !this._liftContentListener) {
752
+ this._liftContentListener = new android.view.View.OnLayoutChangeListener({
753
+ onLayoutChange: () => {
754
+ if (!this._liftTarget || !this._liftTarget.isAttachedToWindow()) {
755
+ this._liftTarget = null;
756
+ const bar = this.hasActionBar ? this.actionBar : null;
757
+ this.syncLiftTarget(bar instanceof AppBar ? bar : null, behavior);
758
+ }
759
+ }
760
+ });
761
+ content.addOnLayoutChangeListener(this._liftContentListener);
762
+ }
763
+ }
604
764
  setStatusScrimColor(color) {
605
765
  this._statusScrim?.setBackgroundColor(color);
606
766
  }
767
+ /** the SearchBar when it hosts the navigation icon and the actions of the bar (the toolbar is hidden) */
768
+ get hostingSearchBar() {
769
+ const toolbar = this.actionBar?.nativeViewProtected;
770
+ return this._searchBar?.getVisibility() === android.view.View.VISIBLE && toolbar?.getVisibility() === android.view.View.GONE ? this._searchBar : null;
771
+ }
607
772
  /** `searchMode="bar"`: a Material 3 SearchBar under the toolbar that opens the full screen SearchView */
608
773
  syncSearchBar(appBar) {
609
774
  const appBarLayout = this._appBarLayout;
610
- const wanted = !!appBar?.searchable && appBar.searchMode === 'bar';
775
+ const wanted = !!appBar?.searchable && (appBar.searchMode === 'bar' || appBar.searchMode === 'inline');
611
776
  if (!wanted) {
612
777
  if (this._searchBar) {
613
778
  this._searchBar.setVisibility(android.view.View.GONE);
779
+ // the toolbar was hidden while the SearchBar hosted its content
780
+ appBar?.nativeViewProtected?.setVisibility(android.view.View.VISIBLE);
614
781
  }
782
+ this._inlineOpen = false;
783
+ this._inlineField?.setVisibility(android.view.View.GONE);
615
784
  return;
616
785
  }
617
786
  const AppBarLayoutParams = com.google.android.material.appbar.AppBarLayout.LayoutParams;
@@ -633,6 +802,12 @@ export class AppPage extends Page {
633
802
  appBarLayout.addView(searchBar);
634
803
  }
635
804
  searchBar.setHint(appBar.searchHint ?? null);
805
+ this.syncSearchBarHost(appBar, searchBar);
806
+ this.syncSearchBarColors(searchBar);
807
+ if (appBar.searchMode === 'inline') {
808
+ this.syncInlineSearch(appBar, searchBar);
809
+ return;
810
+ }
636
811
  const searchView = this._searchView;
637
812
  searchView.setHint(appBar.searchHint ?? null);
638
813
  if (!this._searchBarSetup) {
@@ -671,15 +846,262 @@ export class AppPage extends Page {
671
846
  }));
672
847
  }
673
848
  }
849
+ syncWhenLoaded(view) {
850
+ view.off('loaded', this._syncOnLoaded);
851
+ view.on('loaded', this._syncOnLoaded);
852
+ }
853
+ getAppCompatDrawable(name) {
854
+ const context = this._context;
855
+ const id = context.getResources().getIdentifier(name, 'drawable', context.getPackageName());
856
+ return id ? androidx.core.content.ContextCompat.getDrawable(context, id) : null;
857
+ }
858
+ createInlineButton(drawableName, onClick) {
859
+ const density = Utils.layout.getDisplayDensity();
860
+ const button = new android.widget.ImageView(this._context);
861
+ button.setImageDrawable(this.getAppCompatDrawable(drawableName));
862
+ button.setScaleType(android.widget.ImageView.ScaleType.CENTER);
863
+ button.setLayoutParams(new android.widget.LinearLayout.LayoutParams(Math.round(48 * density), Math.round(48 * density)));
864
+ button.setOnClickListener(new android.view.View.OnClickListener({ onClick }));
865
+ return button;
866
+ }
867
+ notifySearchChange(text) {
868
+ const bar = this._inlineOwner?.get();
869
+ if (bar && this._inlineOpen && bar.searchText !== text) {
870
+ bar.searchText = text;
871
+ bar.notify({ eventName: AppBarBase.searchChangeEvent, object: bar, text });
872
+ }
873
+ }
874
+ notifySearchSubmit(text) {
875
+ const bar = this._inlineOwner?.get();
876
+ bar?.notify({ eventName: AppBarBase.searchSubmitEvent, object: bar, text });
877
+ }
878
+ getInlineText() {
879
+ return this._inlineTextField ? (this._inlineTextField.text ?? '') : this._inlineEdit.getText().toString();
880
+ }
881
+ clearInlineText() {
882
+ if (this._inlineTextField) {
883
+ this._inlineTextField.text = '';
884
+ }
885
+ else {
886
+ this._inlineEdit.setText('');
887
+ }
888
+ }
889
+ dismissInlineKeyboard() {
890
+ if (this._inlineTextField) {
891
+ this._inlineTextField.dismissSoftInput();
892
+ }
893
+ else {
894
+ Utils.dismissSoftInput(this._inlineEdit);
895
+ }
896
+ }
897
+ /** the field of the search: the `searchField` of the bar when it holds a TextField, else the built-in EditText */
898
+ bindInlineTextField(appBar) {
899
+ const host = appBar.searchField;
900
+ if (host && !host.nativeViewProtected) {
901
+ this.syncWhenLoaded(host);
902
+ return;
903
+ }
904
+ const textField = host instanceof TextField ? host : host instanceof ContentView && host.content instanceof TextField ? host.content : null;
905
+ if (textField === this._inlineTextField) {
906
+ return;
907
+ }
908
+ this._inlineTextFieldDetach?.();
909
+ this._inlineTextFieldDetach = null;
910
+ this._inlineTextField = textField;
911
+ const field = this._inlineField;
912
+ const input = textField ? host.nativeViewProtected : this._inlineEdit;
913
+ input.getParent()?.removeView(input);
914
+ field.removeViewAt(1);
915
+ field.addView(input, 1, new android.widget.LinearLayout.LayoutParams(0, WRAP_CONTENT, 1));
916
+ if (textField) {
917
+ const onTextChange = () => this.notifySearchChange(this.getInlineText());
918
+ const onReturnPress = () => {
919
+ this.notifySearchSubmit(this.getInlineText());
920
+ textField.dismissSoftInput();
921
+ };
922
+ textField.on('textChange', onTextChange);
923
+ textField.on('returnPress', onReturnPress);
924
+ this._inlineTextFieldDetach = () => {
925
+ textField.off('textChange', onTextChange);
926
+ textField.off('returnPress', onReturnPress);
927
+ };
928
+ }
929
+ }
930
+ /** `searchMode="inline"`: the SearchBar turns into an editable field in place, the app filters its own content from `searchChange` */
931
+ syncInlineSearch(appBar, searchBar) {
932
+ this._inlineOwner = new WeakRef(appBar);
933
+ searchBar.setOnClickListener(new android.view.View.OnClickListener({ onClick: () => this.openInlineSearch() }));
934
+ if (!this._inlineField) {
935
+ const context = this._context;
936
+ const field = new android.widget.LinearLayout(context);
937
+ field.setOrientation(android.widget.LinearLayout.HORIZONTAL);
938
+ field.setGravity(android.view.Gravity.CENTER_VERTICAL);
939
+ field.setBackground(searchBar.getBackground()?.getConstantState()?.newDrawable()?.mutate() ?? null);
940
+ const edit = new android.widget.EditText(context);
941
+ edit.setBackground(null);
942
+ edit.setSingleLine(true);
943
+ edit.setTextSize(16);
944
+ edit.setPadding(0, 0, 0, 0);
945
+ edit.setImeOptions(android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH);
946
+ edit.addTextChangedListener(new android.text.TextWatcher({
947
+ beforeTextChanged: () => null,
948
+ onTextChanged: () => null,
949
+ afterTextChanged: (editable) => this.notifySearchChange(editable.toString())
950
+ }));
951
+ edit.setOnEditorActionListener(new android.widget.TextView.OnEditorActionListener({
952
+ onEditorAction: () => {
953
+ this.notifySearchSubmit(edit.getText().toString());
954
+ Utils.dismissSoftInput(edit);
955
+ return true;
956
+ }
957
+ }));
958
+ this._inlineBack = this.createInlineButton('abc_ic_ab_back_material', () => this.closeInlineSearch());
959
+ this._inlineClear = this.createInlineButton('abc_ic_clear_material', () => (this.getInlineText().length ? this.clearInlineText() : this.closeInlineSearch()));
960
+ field.addView(this._inlineBack);
961
+ field.addView(edit, new android.widget.LinearLayout.LayoutParams(0, WRAP_CONTENT, 1));
962
+ field.addView(this._inlineClear);
963
+ const appBarLayout = this._appBarLayout;
964
+ field.setVisibility(android.view.View.GONE);
965
+ appBarLayout.addView(field, searchBar.getLayoutParams());
966
+ this._inlineField = field;
967
+ this._inlineEdit = edit;
968
+ }
969
+ this.bindInlineTextField(appBar);
970
+ this.syncInlineColors(appBar);
971
+ this._inlineEdit.setHint(appBar.searchHint ?? null);
972
+ searchBar.setVisibility(this._inlineOpen ? android.view.View.GONE : android.view.View.VISIBLE);
973
+ this._inlineField.setVisibility(this._inlineOpen ? android.view.View.VISIBLE : android.view.View.GONE);
974
+ }
975
+ /** the colors of the theme are read again at each sync (dark / light switch): the field follows the bar */
976
+ syncInlineColors(appBar) {
977
+ const onBar = appBar.getOnBarColor();
978
+ findShapeDrawable(this._inlineField.getBackground())?.setFillColor(android.content.res.ColorStateList.valueOf(this.getSearchBarColor()));
979
+ this._inlineEdit.setTextColor(onBar);
980
+ this._inlineEdit.setHintTextColor(androidx.core.graphics.ColorUtils.setAlphaComponent(onBar, 153));
981
+ this._inlineBack.setColorFilter(onBar);
982
+ this._inlineClear.setColorFilter(onBar);
983
+ }
984
+ getSearchBarColor() {
985
+ return getThemeColor(this._searchBar, 'colorSurfaceContainerHigh', getThemeColor(this._searchBar, 'colorSurface', android.graphics.Color.WHITE));
986
+ }
987
+ /** cross fades the search bar and the editable field (same size and position) */
988
+ morphSearch(from, to) {
989
+ const fade = new android.transition.Fade();
990
+ fade.setDuration(250);
991
+ fade.setInterpolator(new android.view.animation.PathInterpolator(0.4, 0, 0.2, 1));
992
+ android.transition.TransitionManager.beginDelayedTransition(this._appBarLayout, fade);
993
+ from.setVisibility(android.view.View.GONE);
994
+ to.setVisibility(android.view.View.VISIBLE);
995
+ }
996
+ openInlineSearch() {
997
+ if (this._inlineOpen || !this._inlineField) {
998
+ return !!this._inlineOpen;
999
+ }
1000
+ this._inlineOpen = true;
1001
+ const height = this._searchBar.getHeight();
1002
+ if (height > 0) {
1003
+ this._inlineField.setMinimumHeight(height);
1004
+ }
1005
+ this.morphSearch(this._searchBar, this._inlineField);
1006
+ if (this._inlineTextField) {
1007
+ this._inlineTextField.focus();
1008
+ Utils.android.showSoftInput(this._inlineTextField.nativeViewProtected);
1009
+ }
1010
+ else {
1011
+ this._inlineEdit.requestFocus();
1012
+ Utils.android.showSoftInput(this._inlineEdit);
1013
+ }
1014
+ return true;
1015
+ }
1016
+ /** returns true when an inline search was open */
1017
+ closeInlineSearch() {
1018
+ if (!this._inlineOpen) {
1019
+ return false;
1020
+ }
1021
+ this._inlineOpen = false;
1022
+ this.dismissInlineKeyboard();
1023
+ this.clearInlineText();
1024
+ this.morphSearch(this._inlineField, this._searchBar);
1025
+ const bar = this._inlineOwner?.get();
1026
+ if (bar) {
1027
+ bar.searchText = '';
1028
+ bar.notify({ eventName: AppBarBase.searchCancelEvent, object: bar, text: '' });
1029
+ }
1030
+ return true;
1031
+ }
1032
+ /** the SearchBar reads the colors of the theme when it is created: they are applied again at each sync */
1033
+ syncSearchBarColors(searchBar) {
1034
+ findShapeDrawable(searchBar.getBackground())?.setFillColor(android.content.res.ColorStateList.valueOf(this.getSearchBarColor()));
1035
+ const onVariant = getThemeColor(searchBar, 'colorOnSurfaceVariant', android.graphics.Color.BLACK);
1036
+ searchBar.getTextView().setTextColor(onVariant);
1037
+ searchBar.getTextView().setHintTextColor(onVariant);
1038
+ searchBar.getNavigationIcon()?.mutate().setTint(onVariant);
1039
+ }
1040
+ /** without a title the SearchBar is the whole bar (M3 "search app bar"): it takes the navigation icon and the actions of the toolbar */
1041
+ syncSearchBarHost(appBar, searchBar) {
1042
+ const toolbar = appBar.nativeViewProtected;
1043
+ if (this._defaultSearchBarIcon === undefined) {
1044
+ this._defaultSearchBarIcon = searchBar.getNavigationIcon();
1045
+ }
1046
+ const hosted = !appBar.title && !appBar.subtitle && appBar.size === 'small';
1047
+ const menu = searchBar.getMenu();
1048
+ menu.clear();
1049
+ toolbar.setVisibility(hosted ? android.view.View.GONE : android.view.View.VISIBLE);
1050
+ if (!hosted) {
1051
+ searchBar.setNavigationIcon(this._defaultSearchBarIcon);
1052
+ return;
1053
+ }
1054
+ const owner = new WeakRef(appBar);
1055
+ searchBar.setNavigationIcon(toolbar.getNavigationIcon() ?? this._defaultSearchBarIcon);
1056
+ searchBar.setNavigationOnClickListener(new android.view.View.OnClickListener({
1057
+ onClick: () => owner.get()?.navigationButton?._raiseTap()
1058
+ }));
1059
+ const tint = android.content.res.ColorStateList.valueOf(appBar.getOnBarColor());
1060
+ const source = toolbar.getMenu();
1061
+ for (let index = 0; index < source.size(); index++) {
1062
+ const item = source.getItem(index);
1063
+ const copy = menu.add(item.getGroupId(), item.getItemId(), item.getOrder(), item.getTitle());
1064
+ copy.setIcon(item.getIcon());
1065
+ copy.setShowAsAction(android.view.MenuItem.SHOW_AS_ACTION_ALWAYS);
1066
+ androidx.core.view.MenuItemCompat.setIconTintList(copy, tint);
1067
+ }
1068
+ searchBar.setOnMenuItemClickListener(new androidx.appcompat.widget.Toolbar.OnMenuItemClickListener({
1069
+ onMenuItemClick: (menuItem) => {
1070
+ const items = (owner.get()?.actionItems?.getVisibleItems() ?? []);
1071
+ items.find((item) => item._getItemId() === menuItem.getItemId())?._raiseTap();
1072
+ return true;
1073
+ }
1074
+ }));
1075
+ }
674
1076
  /** `bottomItems`: a Material BottomAppBar, over the content, below the overlay */
675
1077
  syncBottomBar(appBar, behavior) {
676
- const items = (appBar?.bottomItems || []).filter((item) => !item.spacer);
1078
+ const items = (appBar?.bottomItems || []).filter((item) => !item.spacer && !item.search);
677
1079
  const coordinator = this._coordinator;
678
- if (!items.length) {
1080
+ const floating = appBar?.bottomBarStyle !== 'bottomAppBar';
1081
+ // a bottomView only fits the floating toolbar: the BottomAppBar is a Toolbar with a menu
1082
+ if (floating && appBar?.bottomView && !appBar.bottomView.nativeViewProtected) {
1083
+ this.syncWhenLoaded(appBar.bottomView);
1084
+ }
1085
+ const hasContent = items.length > 0 || (floating && !!appBar?.bottomView?.nativeViewProtected);
1086
+ if (!hasContent || floating) {
679
1087
  if (this._bottomBar) {
680
1088
  this._bottomBar.setVisibility(android.view.View.GONE);
681
- this.syncBottomInset(appBar, false);
1089
+ if (!floating) {
1090
+ this.syncBottomInset(appBar, false, this._bottomBar);
1091
+ }
682
1092
  }
1093
+ }
1094
+ if (!hasContent || !floating) {
1095
+ if (this._floatingBar) {
1096
+ this._floatingBar.setVisibility(android.view.View.GONE);
1097
+ this.syncBottomInset(appBar, false, this._floatingBar);
1098
+ }
1099
+ }
1100
+ if (!hasContent) {
1101
+ return;
1102
+ }
1103
+ if (floating) {
1104
+ this.syncFloatingBar(appBar, items, behavior);
683
1105
  return;
684
1106
  }
685
1107
  const BottomAppBar = com.google.android.material.bottomappbar.BottomAppBar;
@@ -698,12 +1120,14 @@ export class AppPage extends Page {
698
1120
  const menu = bar.getMenu();
699
1121
  menu.clear();
700
1122
  const owner = new WeakRef(appBar);
1123
+ const onBarList = android.content.res.ColorStateList.valueOf(appBar.getOnBarColor());
701
1124
  items.forEach((item, index) => {
702
1125
  const menuItem = menu.add(0, index, index, item.text || '');
703
1126
  const icon = item.icon ? getNativeIcon(item.icon) : null;
704
1127
  if (icon) {
705
1128
  menuItem.setIcon(icon);
706
1129
  }
1130
+ androidx.core.view.MenuItemCompat.setIconTintList(menuItem, onBarList);
707
1131
  menuItem.setShowAsAction(android.view.MenuItem.SHOW_AS_ACTION_ALWAYS);
708
1132
  });
709
1133
  attachBadges(bar, this._bottomBadges, items
@@ -712,20 +1136,92 @@ export class AppPage extends Page {
712
1136
  bar.setOnMenuItemClickListener(new androidx.appcompat.widget.Toolbar.OnMenuItemClickListener({
713
1137
  onMenuItemClick: (menuItem) => {
714
1138
  const target = owner.get();
715
- if (target) {
716
- notifyBottomItemTap(target, items[menuItem.getItemId()]);
1139
+ const item = items[menuItem.getItemId()];
1140
+ if (target && item?.menu?.length) {
1141
+ showMenu({ anchor: { nativeViewProtected: bar }, items: item.menu, placement: { horizontal: 'end', vertical: 'above' } });
1142
+ }
1143
+ else if (target) {
1144
+ notifyBottomItemTap(target, item);
717
1145
  }
718
1146
  return true;
719
1147
  }
720
1148
  }));
721
- this.syncBottomInset(appBar, true);
1149
+ this.syncBottomInset(appBar, true, bar);
1150
+ }
1151
+ /** `bottomBarStyle="floating"`: a Material 3 Expressive FloatingToolbarLayout (a pill over the content, centered above the gesture bar) holding icon buttons, or the `bottomView` of the bar */
1152
+ syncFloatingBar(appBar, items, behavior) {
1153
+ const coordinator = this._coordinator;
1154
+ const context = this._context;
1155
+ const density = Utils.layout.getDisplayDensity();
1156
+ const CoordinatorLayout = androidx.coordinatorlayout.widget.CoordinatorLayout;
1157
+ if (!this._floatingBar) {
1158
+ // the typings of the Material version of the plugin do not have the floating toolbar yet
1159
+ const FloatingToolbarLayout = com.google.android.material.floatingtoolbar.FloatingToolbarLayout;
1160
+ const bar = new FloatingToolbarLayout(context);
1161
+ const params = new CoordinatorLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
1162
+ params.gravity = android.view.Gravity.BOTTOM | android.view.Gravity.CENTER_HORIZONTAL;
1163
+ const margin = Math.round(16 * density);
1164
+ params.setMargins(margin, margin, margin, margin);
1165
+ // below the overlay (a FAB, a panel)
1166
+ coordinator.addView(bar, coordinator.indexOfChild(this._nativeOverlay), params);
1167
+ const holder = new android.widget.LinearLayout(context);
1168
+ holder.setOrientation(android.widget.LinearLayout.HORIZONTAL);
1169
+ holder.setGravity(android.view.Gravity.CENTER_VERTICAL);
1170
+ bar.addView(holder, new android.widget.FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
1171
+ this._floatingBar = bar;
1172
+ this._floatingHolder = holder;
1173
+ }
1174
+ const bar = this._floatingBar;
1175
+ const holder = this._floatingHolder;
1176
+ const params = bar.getLayoutParams();
1177
+ params.setBehavior(behavior === 'hide' ? new com.google.android.material.behavior.HideBottomViewOnScrollBehavior() : null);
1178
+ bar.setVisibility(android.view.View.VISIBLE);
1179
+ const background = bar.getBackground();
1180
+ if (background instanceof com.google.android.material.shape.MaterialShapeDrawable) {
1181
+ background.setFillColor(android.content.res.ColorStateList.valueOf(getThemeColor(bar, 'colorSurfaceContainer', getThemeColor(bar, 'colorSurface', android.graphics.Color.WHITE))));
1182
+ }
1183
+ holder.removeAllViews();
1184
+ const customView = appBar.bottomView?.nativeViewProtected;
1185
+ if (customView) {
1186
+ customView.getParent()?.removeView(customView);
1187
+ holder.addView(customView, new android.widget.LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
1188
+ this.syncBottomInset(appBar, true, bar);
1189
+ return;
1190
+ }
1191
+ const owner = new WeakRef(appBar);
1192
+ const ripple = new android.util.TypedValue();
1193
+ context.getTheme().resolveAttribute(16843868 /* android.R.attr.selectableItemBackgroundBorderless */, ripple, true);
1194
+ const size = Math.round(48 * density);
1195
+ const onBarList = android.content.res.ColorStateList.valueOf(appBar.getOnBarColor());
1196
+ items.forEach((item) => {
1197
+ const button = new android.widget.ImageButton(context);
1198
+ const icon = item.icon ? getNativeIcon(item.icon) : null;
1199
+ if (icon) {
1200
+ button.setImageDrawable(icon);
1201
+ }
1202
+ button.setImageTintList(onBarList);
1203
+ button.setContentDescription(item.text || '');
1204
+ button.setBackground(androidx.core.content.ContextCompat.getDrawable(context, ripple.resourceId));
1205
+ button.setOnClickListener(new android.view.View.OnClickListener({
1206
+ onClick: () => {
1207
+ const target = owner.get();
1208
+ if (target && item.menu?.length) {
1209
+ showMenu({ anchor: { nativeViewProtected: button }, items: item.menu, placement: { horizontal: 'end', vertical: 'above' } });
1210
+ }
1211
+ else if (target) {
1212
+ notifyBottomItemTap(target, item);
1213
+ }
1214
+ }
1215
+ }));
1216
+ holder.addView(button, new android.widget.LinearLayout.LayoutParams(size, size));
1217
+ });
1218
+ this.syncBottomInset(appBar, true, bar);
722
1219
  }
723
1220
  /** the last items of the scrolling view must not stay under the bottom bar */
724
- syncBottomInset(appBar, visible) {
725
- const bar = this._bottomBar;
726
- const target = appBar?.scrollTarget?.nativeViewProtected;
1221
+ syncBottomInset(appBar, visible, bar) {
1222
+ const target = appBar?.getScrollTarget()?.nativeViewProtected ?? this._liftTarget;
727
1223
  if (this._bottomInsetListener) {
728
- bar.removeOnLayoutChangeListener(this._bottomInsetListener);
1224
+ this._bottomInsetView?.removeOnLayoutChangeListener(this._bottomInsetListener);
729
1225
  this._bottomInsetListener = null;
730
1226
  }
731
1227
  if (!target) {
@@ -738,6 +1234,8 @@ export class AppPage extends Page {
738
1234
  }
739
1235
  return;
740
1236
  }
1237
+ // a floating bar also has margins around it
1238
+ const occupied = () => bar.getHeight() + (bar.getLayoutParams()?.bottomMargin ?? 0);
741
1239
  const apply = () => {
742
1240
  if (target['__appBarBottomPadding'] === undefined) {
743
1241
  target['__appBarBottomPadding'] = target.getPaddingBottom();
@@ -745,22 +1243,23 @@ export class AppPage extends Page {
745
1243
  if (target instanceof android.view.ViewGroup) {
746
1244
  target.setClipToPadding(false);
747
1245
  }
748
- target.setPadding(target.getPaddingLeft(), target.getPaddingTop(), target.getPaddingRight(), bar.getHeight());
1246
+ target.setPadding(target.getPaddingLeft(), target.getPaddingTop(), target.getPaddingRight(), occupied());
749
1247
  };
750
1248
  this._bottomInsetListener = new android.view.View.OnLayoutChangeListener({
751
1249
  onLayoutChange: () => {
752
- if (target.getPaddingBottom() !== bar.getHeight() && this._bottomInsetListener) {
1250
+ if (target.getPaddingBottom() !== occupied() && this._bottomInsetListener) {
753
1251
  apply();
754
1252
  }
755
1253
  }
756
1254
  });
1255
+ this._bottomInsetView = bar;
757
1256
  bar.addOnLayoutChangeListener(this._bottomInsetListener);
758
1257
  apply();
759
1258
  }
760
1259
  /** translucent: the first items of the scrolling view must start below the bar: its top padding follows the height of the bar */
761
1260
  syncTranslucentInset(appBar, translucent) {
762
1261
  const appBarLayout = this._appBarLayout;
763
- const target = appBar?.scrollTarget?.nativeViewProtected;
1262
+ const target = appBar?.getScrollTarget()?.nativeViewProtected;
764
1263
  if (this._insetListener) {
765
1264
  appBarLayout.removeOnLayoutChangeListener(this._insetListener);
766
1265
  this._insetListener = null;