Search code examples
c#wpfxamlitemscontrolwrappanel

Force a break in a items control with wrap panel template


I've got an interesting problem I've been trying to solve. Basically I have an items control that uses a WrapPanel as it's ItemsPanel to simulate a paragraph built from several bound strings. However there are times where I need to force a break, like when I start a new paragraph, however putting a break into the TextBlock DateTemplate does not actually put a break into the parent wrap panel. Here is the code:

<ItemsControl ItemsSource="{Binding Fragments}" >
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <WrapPanel />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <TextBlock
                TextWrapping="Wrap"
                Text="{Binding}"/> <!--If this text has a break it won't 
                                        propagate that break to the wrap panel,
                                        but instead just in this text block which
                                        causes the formatting to look wrong-->
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

Here is a simple definition for fragments that will show what I am talking about:

Fragments = new ObservableCollection<string>();
Fragments.Add("This is");
Fragments.Add("the first line, ");
Fragments.Add("it is very long and will drift to the ");
Fragments.Add("second line naturally since it is controlled by a wrap panel");
Fragments.Add("\n\r This I want to force to the line below where the line above ends");
Fragments.Add("rapid \n\r new \n\r lines");

enter image description here

I would like this to flow as paragraphs that just continue to get concatenated, but honor the manual breaks when they are run into. Like this:

This is the first line, it is very long and will drift to the second line 
naturally since it is controlled by a wrap panel.
This I want to force to the line below where the line above ends.
rapid
new
lines

Solution

  • I would chuck the ItemsControl and use the Inlines collection of a textblock instead. Unfortunately you cannot bind your collection of strings directly, because TextBlock.Inlines is not a dependency property, but it's not hard to work around that with an attached dependency property:

    I have also added support for propagation of the CollectionChanged event, so adding a string to ViewModel.Fragments will update the textblock. Removing will work too, although with the limitation that the first Fragment matching the string will be removed.

    enter image description here

    <Window x:Class="WpfApplication1.MainWindow"
            xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
            xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
            xmlns:local="clr-namespace:WpfApplication1"
            Title="MainWindow" Height="350" Width="525">
    
        <Window.DataContext>
            <local:ViewModel />
        </Window.DataContext>
    
        <Grid>
            <TextBlock local:FlowSupport.Fragments="{Binding Fragments}" TextWrapping="WrapWithOverflow" Margin="10" Background="Beige" />
        </Grid>
    </Window>
    

    ViewModel:

    public class ViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;
        private void OnPropertyChanged(string propertyName)
        {
            if (this.PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    
        private ObservableCollection<string> _fragments;
        public ObservableCollection<string> Fragments { get { return _fragments; } set { _fragments = value; OnPropertyChanged("Fragments"); } }
    
        public ViewModel()
        {
            Fragments = new ObservableCollection<string>();
            Fragments.Add("This is ");
            Fragments.Add("the first line, ");
            Fragments.Add("it is very long and will drift to the ");
            Fragments.Add("second line naturally since it is controlled by a wrap panel");
            Fragments.Add("\nThis I want to force to the line below where the line above ends\n");
            Fragments.Add("rapid \nnew \nlines");
        }
    }
    

    FlowSupport:

    using System;
    using System.Linq;
    using System.Collections.Generic;
    using System.Collections.ObjectModel;
    using System.Windows;
    using System.Windows.Controls;
    using System.Windows.Documents;
    using System.Collections.Specialized;
    
    namespace WpfApplication1
    {
        public static class FlowSupport
        {
            private static Dictionary<TextBlock, NotifyCollectionChangedEventHandler> _collChangedHandlers = new Dictionary<TextBlock,NotifyCollectionChangedEventHandler>();
    
            public static ObservableCollection<string> GetFragments(TextBlock tb) { return (ObservableCollection<string>)tb.GetValue(FragmentsProperty); }
            public static void SetFragments(TextBlock tb, ObservableCollection<string> value) { tb.SetValue(FragmentsProperty, value); }
    
            public static readonly DependencyProperty FragmentsProperty = DependencyProperty.RegisterAttached("Fragments", typeof(ObservableCollection<string>), typeof(FlowSupport), new PropertyMetadata(new ObservableCollection<string>(), OnFragmentsChanged));
    
            private static void OnFragmentsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
            {
                var tb = d as TextBlock;
                if (tb != null)
                {
                    CreateCollectionChangedHandler(tb); // create handler, once per textblock
    
                    tb.Inlines.Clear();
                    var oldInlines = e.OldValue as ObservableCollection<string>;
                    if (oldInlines != null)
                    {
                        oldInlines.CollectionChanged -= _collChangedHandlers[tb];
                    }
                    var inlines = e.NewValue as ObservableCollection<string>;
                    if (inlines != null)
                    {
                        inlines.CollectionChanged += _collChangedHandlers[tb];
    
                        foreach (string s in inlines)
                            tb.Inlines.Add(s);
                    }
                }
            }
    
            private static void CreateCollectionChangedHandler(TextBlock tb)
            {
                if (!_collChangedHandlers.ContainsKey(tb))
                {
                    _collChangedHandlers.Add(tb, (s1, e1) =>
                    {
                        if (e1.NewItems != null)
                        {
                            foreach (string text in e1.NewItems)
                                tb.Inlines.Add(text);
                        }
                        if (e1.OldItems != null)
                        {
                            foreach (string text in e1.OldItems)
                            {
                                Inline inline = tb.Inlines.FirstOrDefault(i => ((Run)i).Text == text);
                                if (inline != null)
                                    tb.Inlines.Remove(inline);
                            }
                        }
                    });
                }
            }
        }
    }