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