@@ -618,6 +618,148 @@ def onStopTrackingTouch(self, seekBar: Any) -> None:
618618 sb .setOnSeekBarChangeListener (SeekProxy (cb , min_val , rng ))
619619
620620
621+ _android_tabbar_state : dict = {"callback" : None , "items" : []}
622+
623+
624+ class TabBarHandler (ViewHandler ):
625+ """Native tab bar using ``BottomNavigationView`` from Material Components.
626+
627+ Falls back to a horizontal ``LinearLayout`` with ``Button`` children
628+ when Material Components is unavailable.
629+ """
630+
631+ _is_material : bool = True
632+
633+ def create (self , props : Dict [str , Any ]) -> Any :
634+ try :
635+ bnv = jclass ("com.google.android.material.bottomnavigation.BottomNavigationView" )(_ctx ())
636+ bnv .setBackgroundColor (parse_color_int ("#FFFFFF" ))
637+ ViewGroupLP = jclass ("android.view.ViewGroup$LayoutParams" )
638+ LayoutParams = jclass ("android.widget.LinearLayout$LayoutParams" )
639+ lp = LayoutParams (ViewGroupLP .MATCH_PARENT , ViewGroupLP .WRAP_CONTENT )
640+ bnv .setLayoutParams (lp )
641+ self ._is_material = True
642+ self ._apply_full (bnv , props )
643+ return bnv
644+ except Exception :
645+ self ._is_material = False
646+ return self ._create_fallback (props )
647+
648+ def _create_fallback (self , props : Dict [str , Any ]) -> Any :
649+ """Horizontal LinearLayout with Button children as a tab-bar fallback."""
650+ LinearLayout = jclass ("android.widget.LinearLayout" )
651+ ll = LinearLayout (_ctx ())
652+ ll .setOrientation (LinearLayout .HORIZONTAL )
653+ ll .setBackgroundColor (parse_color_int ("#F8F8F8" ))
654+ self ._apply_fallback (ll , props )
655+ return ll
656+
657+ def update (self , native_view : Any , changed : Dict [str , Any ]) -> None :
658+ if self ._is_material :
659+ self ._apply_partial (native_view , changed )
660+ else :
661+ self ._apply_fallback (native_view , changed )
662+
663+ def _apply_full (self , bnv : Any , props : Dict [str , Any ]) -> None :
664+ """Initial creation — all props are present."""
665+ items = props .get ("items" , [])
666+ self ._set_menu (bnv , items )
667+ self ._set_active (bnv , props .get ("active_tab" ), items )
668+ cb = props .get ("on_tab_select" )
669+ if cb is not None :
670+ self ._set_listener (bnv , cb , items )
671+
672+ def _apply_partial (self , bnv : Any , changed : Dict [str , Any ]) -> None :
673+ """Reconciler update — only changed props are present."""
674+ prev_items = _android_tabbar_state ["items" ]
675+
676+ if "items" in changed :
677+ items = changed ["items" ]
678+ self ._set_menu (bnv , items )
679+ else :
680+ items = prev_items
681+
682+ if "active_tab" in changed :
683+ self ._set_active (bnv , changed ["active_tab" ], items )
684+
685+ if "on_tab_select" in changed :
686+ cb = changed ["on_tab_select" ]
687+ if cb is not None :
688+ self ._set_listener (bnv , cb , items )
689+
690+ def _set_menu (self , bnv : Any , items : list ) -> None :
691+ _android_tabbar_state ["items" ] = items
692+ try :
693+ menu = bnv .getMenu ()
694+ menu .clear ()
695+ for i , item in enumerate (items ):
696+ title = item .get ("title" , item .get ("name" , "" ))
697+ menu .add (0 , i , i , str (title ))
698+ except Exception :
699+ pass
700+
701+ def _set_active (self , bnv : Any , active : Any , items : list ) -> None :
702+ if active and items :
703+ for i , item in enumerate (items ):
704+ if item .get ("name" ) == active :
705+ try :
706+ bnv .setSelectedItemId (i )
707+ except Exception :
708+ pass
709+ break
710+
711+ def _set_listener (self , bnv : Any , cb : Callable , items : list ) -> None :
712+ _android_tabbar_state ["callback" ] = cb
713+ _android_tabbar_state ["items" ] = items
714+ try :
715+ listener_cls = jclass ("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener" )
716+
717+ class _TabSelectProxy (dynamic_proxy (listener_cls )):
718+ def __init__ (self , callback : Callable , tab_items : list ) -> None :
719+ super ().__init__ ()
720+ self .callback = callback
721+ self .tab_items = tab_items
722+
723+ def onNavigationItemSelected (self , menu_item : Any ) -> bool :
724+ idx = menu_item .getItemId ()
725+ if 0 <= idx < len (self .tab_items ):
726+ self .callback (self .tab_items [idx ].get ("name" , "" ))
727+ return True
728+
729+ bnv .setOnItemSelectedListener (_TabSelectProxy (cb , items ))
730+ except Exception :
731+ pass
732+
733+ def _apply_fallback (self , ll : Any , props : Dict [str , Any ]) -> None :
734+ items = props .get ("items" , [])
735+ active = props .get ("active_tab" )
736+ cb = props .get ("on_tab_select" )
737+ if "items" in props :
738+ ll .removeAllViews ()
739+ for item in items :
740+ name = item .get ("name" , "" )
741+ title = item .get ("title" , name )
742+ btn = jclass ("android.widget.Button" )(_ctx ())
743+ btn .setText (str (title ))
744+ btn .setEnabled (name != active )
745+ if cb is not None :
746+ tab_name = name
747+
748+ def _make_click (n : str ) -> Callable [[], None ]:
749+ return lambda : cb (n )
750+
751+ class _ClickProxy (dynamic_proxy (jclass ("android.view.View" ).OnClickListener )):
752+ def __init__ (self , callback : Callable [[], None ]) -> None :
753+ super ().__init__ ()
754+ self .callback = callback
755+
756+ def onClick (self , view : Any ) -> None :
757+ self .callback ()
758+
759+ btn .setOnClickListener (_ClickProxy (_make_click (tab_name )))
760+ ll .addView (btn )
761+
762+
621763class PressableHandler (ViewHandler ):
622764 def create (self , props : Dict [str , Any ]) -> Any :
623765 fl = jclass ("android.widget.FrameLayout" )(_ctx ())
@@ -686,4 +828,5 @@ def register_handlers(registry: Any) -> None:
686828 registry .register ("SafeAreaView" , SafeAreaViewHandler ())
687829 registry .register ("Modal" , ModalHandler ())
688830 registry .register ("Slider" , SliderHandler ())
831+ registry .register ("TabBar" , TabBarHandler ())
689832 registry .register ("Pressable" , PressableHandler ())
0 commit comments