求带详解的Xamarin Forms Expandable Tree View与Android Expandable List View实现方案
Hey there! Let's tackle your expandable tree view requirement in Xamarin.Forms, with a native Android ExpandableListView implementation that matches the look and feel you're targeting. I'll break this down into actionable steps with detailed explanations so you understand how each part works together.
1. Create a Reusable Expandable Node Model
First, we need a data model that represents each node in the tree. This model tracks expansion state, child nodes, and display content, with built-in UI update notifications.
using System.Collections.ObjectModel; using System.ComponentModel; using System.Runtime.CompilerServices; public class ExpandableNode : INotifyPropertyChanged { private bool _isExpanded; private string _title; public string Title { get => _title; set { _title = value; OnPropertyChanged(); } } public bool IsExpanded { get => _isExpanded; set { _isExpanded = value; OnPropertyChanged(); } } public ObservableCollection<ExpandableNode> Children { get; set; } public ExpandableNode() { Children = new ObservableCollection<ExpandableNode>(); IsExpanded = false; // Start collapsed by default } public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }
Explanation:
INotifyPropertyChangedensures the UI reacts instantly whenIsExpandedorTitlechanges.ObservableCollection<ExpandableNode>auto-refreshes the UI when child nodes are added/removed.IsExpandedcontrols whether the node's children are visible in the interface.
2. Xamarin.Forms Cross-Platform Tree View UI
For the shared UI, we'll build a custom TreeView control using ListView and a reusable ViewCell that handles expand/collapse logic.
2.1 The TreeView Control (Shared Project)
A wrapper around ListView to standardize tree view behavior:
using Xamarin.Forms; public class TreeView : ListView { public static readonly BindableProperty ItemsSourceProperty = BindableProperty.Create(nameof(ItemsSource), typeof(ObservableCollection<ExpandableNode>), typeof(TreeView)); public ObservableCollection<ExpandableNode> ItemsSource { get => (ObservableCollection<ExpandableNode>)GetValue(ItemsSourceProperty); set => SetValue(ItemsSourceProperty, value); } public TreeView() { ItemTemplate = new DataTemplate(() => new TreeViewCell()); SeparatorVisibility = SeparatorVisibility.None; CachingStrategy = ListViewCachingStrategy.RecycleElement; // Optimize for large datasets } }
2.2 The TreeViewCell (Custom ViewCell)
This cell displays the parent node, an expand/collapse icon, and nested child nodes:
<ViewCell xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Class="YourNamespace.TreeViewCell"> <StackLayout Spacing="0"> <!-- Parent Node Header --> <Grid x:Name="HeaderGrid" Padding="10,8" BackgroundColor="#F5F5F5"> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto" /> <ColumnDefinition Width="*" /> </Grid.ColumnDefinitions> <!-- Expand/Collapse Icon --> <Image Source="{Binding IsExpanded, Converter={StaticResource BoolToIconConverter}}" WidthRequest="20" HeightRequest="20" VerticalOptions="Center" /> <!-- Node Title --> <Label Grid.Column="1" Text="{Binding Title}" FontSize="16" VerticalOptions="Center" /> </Grid> <!-- Child Nodes (Hidden by default) --> <ListView ItemsSource="{Binding Children}" ItemTemplate="{StaticResource TreeViewCellTemplate}" IsVisible="{Binding IsExpanded}" SeparatorVisibility="None" Margin="20,0,0,0" CachingStrategy="RecycleElement" /> </StackLayout> </ViewCell>
2.3 BoolToIconConverter
Swaps icons based on the node's expansion state:
using Xamarin.Forms; public class BoolToIconConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { return (bool)value ? "ic_collapse.png" : "ic_expand.png"; } public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { throw new NotImplementedException(); } }
2.4 Handle Expand/Collapse Taps
Add a tap gesture to the header to toggle the node's state:
public partial class TreeViewCell : ViewCell { public TreeViewCell() { InitializeComponent(); var tapGesture = new TapGestureRecognizer(); tapGesture.Tapped += (s, e) => { if (BindingContext is ExpandableNode node) { node.IsExpanded = !node.IsExpanded; } }; HeaderGrid.GestureRecognizers.Add(tapGesture); } }
Explanation:
- The nested child
ListViewonly appears whenIsExpandedistrue. - The tap gesture triggers a state change, which updates the UI via
INotifyPropertyChanged. - The converter provides visual feedback for expansion state.
3. Android Native ExpandableListView Implementation
To get the smooth, native Android ExpandableListView experience, we'll build a custom renderer that replaces the cross-platform TreeView with Android's native control.
3.1 Marker Control (Shared Project)
A placeholder control to trigger Android-specific rendering:
public class NativeTreeView : TreeView { // Marker class for Android custom rendering }
3.2 Android Custom Renderer
Maps the NativeTreeView to Android's ExpandableListView:
using Android.Content; using Android.Widget; using Xamarin.Forms; using Xamarin.Forms.Platform.Android; using YourNamespace; using YourNamespace.Droid; [assembly: ExportRenderer(typeof(NativeTreeView), typeof(NativeTreeViewRenderer))] namespace YourNamespace.Droid { public class NativeTreeViewRenderer : ViewRenderer<NativeTreeView, ExpandableListView> { private ExpandableListAdapter _adapter; public NativeTreeViewRenderer(Context context) : base(context) { } protected override void OnElementChanged(ElementChangedEventArgs<NativeTreeView> e) { base.OnElementChanged(e); if (Control == null) { var expandableListView = new ExpandableListView(Context); SetNativeControl(expandableListView); } if (e.NewElement != null) { _adapter = new ExpandableListAdapter(Context, e.NewElement.ItemsSource); Control.SetAdapter(_adapter); // Sync expansion state between model and native control Control.GroupCollapse += (s, args) => { var node = e.NewElement.ItemsSource[args.GroupPosition]; node.IsExpanded = false; }; Control.GroupExpand += (s, args) => { var node = e.NewElement.ItemsSource[args.GroupPosition]; node.IsExpanded = true; }; } } } }
3.3 ExpandableListAdapter (Android)
Converts our cross-platform ExpandableNode model to Android's group/child structure:
using Android.Content; using Android.Views; using Android.Widget; using System.Collections.ObjectModel; namespace YourNamespace.Droid { public class ExpandableListAdapter : BaseExpandableListAdapter { private readonly Context _context; private readonly ObservableCollection<ExpandableNode> _groupNodes; public ExpandableListAdapter(Context context, ObservableCollection<ExpandableNode> groupNodes) { _context = context; _groupNodes = groupNodes; } public override Java.Lang.Object GetChild(int groupPosition, int childPosition) { return _groupNodes[groupPosition].Children[childPosition].Title; } public override long GetChildId(int groupPosition, int childPosition) { return childPosition; } public override View GetChildView(int groupPosition, int childPosition, bool isLastChild, View convertView, ViewGroup parent) { if (convertView == null) { var inflater = (LayoutInflater)_context.GetSystemService(Context.LayoutInflaterService); convertView = inflater.Inflate(Resource.Layout.child_item, parent, false); } var childTitle = convertView.FindViewById<TextView>(Resource.Id.child_title); childTitle.Text = _groupNodes[groupPosition].Children[childPosition].Title; return convertView; } public override int GetChildrenCount(int groupPosition) { return _groupNodes[groupPosition].Children.Count; } public override Java.Lang.Object GetGroup(int groupPosition) { return _groupNodes[groupPosition].Title; } public override long GetGroupId(int groupPosition) { return groupPosition; } public override View GetGroupView(int groupPosition, bool isExpanded, View convertView, ViewGroup parent) { if (convertView == null) { var inflater = (LayoutInflater)_context.GetSystemService(Context.LayoutInflaterService); convertView = inflater.Inflate(Resource.Layout.group_item, parent, false); } var groupTitle = convertView.FindViewById<TextView>(Resource.Id.group_title); var expandIcon = convertView.FindViewById<ImageView>(Resource.Id.expand_icon); groupTitle.Text = _groupNodes[groupPosition].Title; expandIcon.SetImageResource(isExpanded ? Resource.Drawable.ic_collapse : Resource.Drawable.ic_expand); return convertView; } public override int GroupCount => _groupNodes.Count; public override bool HasStableIds => true; public override bool IsChildSelectable(int groupPosition, int childPosition) { return true; // Allow child node selection } } }
3.4 Android Layout Files
Create two layouts in your Android project's Resources/Layout folder:
group_item.axml (parent node layout):
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp" android:orientation="horizontal" android:gravity="center_vertical"> <ImageView android:id="@+id/expand_icon" android:layout_width="24dp" android:layout_height="24dp" android:layout_marginRight="12dp"/> <TextView android:id="@+id/group_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="18sp" android:textColor="#333333"/> </LinearLayout>
child_item.axml (child node layout):
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp" android:paddingLeft="40dp" android:orientation="horizontal"> <TextView android:id="@+id/child_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" android:textColor="#666666"/> </LinearLayout>
Explanation:
- The custom renderer replaces the cross-platform
TreeViewwith Android's native control, which provides smooth expand/collapse animations and native touch behavior. - The adapter bridges our cross-platform model to Android's group/child view system.
- The layouts define the native look and feel for parent and child nodes.
4. Best Practices & Optimizations
- Performance: Use
RecycleElementcaching strategy in Xamarin.FormsListViews to reduce memory usage for large datasets. - Data Updates: Always use
ObservableCollectionfor child nodes to ensure automatic UI refreshes. - Memory Management: In the Android adapter, avoid holding strong references to the context (use
WeakReferenceif needed) to prevent memory leaks. - Platform Consistency: For iOS, you can either use the cross-platform
TreeViewor build a custom renderer usingUITableViewwith expandable sections.
内容的提问来源于stack exchange,提问作者HarshShah




