I'm having some trouble with WPF custom task panes in Excel-DNA. To start, here's a simple C# implementation of a task pane wired up to the ribbon. Constructive criticism welcome, etc.
WpfTaskPaneTest.dna
<DnaLibrary Name="WPFTaskPaneTest" RuntimeVersion="v4.0" Language="CS">
<ExternalLibrary Path="WpfTaskPaneTest.dll" ExplicitExports="true" />
<CustomUI>
<customUI xmlns="http://schemas.microsoft.com/office/2006/01/customui"
onLoad="OnLoadCust">
<ribbon>
<tabs>
<tab id="WPFTP" label="WPFTPT">
<group id="WPFTPg" label="WPFTPT">
<toggleButton id="WPFTPb" label="Show Task Pane"
getPressed="GetPaneVisible" onAction="TogglePane" />
</group>
</tab>
</tabs>
</ribbon>
</customUI>
</CustomUI>
</DnaLibrary>
using ExcelDna.Integration;
using ExcelDna.Integration.CustomUI;
using ExcelDna.Integration.Extensibility;
using System;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Forms.Integration;
using Forms = System.Windows.Forms;
using WpfApp = System.Windows.Application;
using XamlReader = System.Windows.Markup.XamlReader;
namespace WPFTaskPaneTest {
[ComVisible(true)]
public sealed class PaneHost : Forms.UserControl {
public ElementHost Host;
public PaneHost() {
SuspendLayout();
Host = new ElementHost() {
BackColor = System.Drawing.Color.Transparent,
BackColorTransparent = true,
Dock = Forms.DockStyle.Fill,
Location = new System.Drawing.Point(0, 0),
Name = "Host",
Size = new System.Drawing.Size(180, 600),
TabIndex = 0,
TabStop = false,
Text = "Host",
Child = null
};
AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
AutoScaleMode = Forms.AutoScaleMode.Font;
AutoScroll = true;
Controls.Add(Host);
Margin = new Forms.Padding(0);
Name = "PaneHost";
Size = new System.Drawing.Size(180, 600);
ResumeLayout(false);
}
}
[ComVisible(true)]
public sealed class RibbonUi : ExcelRibbon {
private IRibbonUI _uiCtl;
public override void OnConnection(object application, ext_ConnectMode connectMode,
object addInInst, ref Array custom) {
new WpfApp { ShutdownMode = ShutdownMode.OnExplicitShutdown };
base.OnConnection(application, connectMode, addInInst, ref custom);
}
public override void OnDisconnection(ext_DisconnectMode removeMode, ref Array custom) {
TaskPaneMgr.Shutdown();
WpfApp.Current.Shutdown();
GC.Collect();
GC.WaitForPendingFinalizers();
base.OnDisconnection(removeMode, ref custom);
}
public bool GetPaneVisible(IRibbonControl ctl) => TaskPaneMgr.PaneVisible;
public void OnLoadCust(IRibbonUI obj) {
_uiCtl = obj;
Globals.OnTaskPaneVisibilityChanged += () => _uiCtl?.InvalidateControl("WPFTPb");
obj.Invalidate();
}
public void TogglePane(IRibbonControl ctl, bool state) {
TaskPaneMgr.PaneVisible = state;
}
}
static class Globals {
public static event Action OnTaskPaneVisibilityChanged; // TaskPaneMgr cannot host its own event
public static void NotifyTaskPaneVisibilityChanged() => OnTaskPaneVisibilityChanged?.Invoke();
}
static class TaskPaneMgr {
static CustomTaskPane _ctp;
static IntPtr _ctpParentHwnd;
static int _paneWidth = 180;
static UIElement _wpfPane;
public static bool PaneVisible {
get { return _ctp != null && _ctpParentHwnd == ExcelDnaUtil.WindowHandle && _ctp.Visible; }
set {
if (value) {
if (!PaneVisible) {
EnsurePaneCreated();
try {
// hide the ElementHost while toggling function pane visibility,
// to reduce flicker under Excel 2013/2016
var eh = (ElementHost)((PaneHost)_ctp.ContentControl).Host;
eh.Visible = false;
_ctp.Visible = true;
eh.Visible = true;
} catch (COMException) {
_ctp = null;
EnsurePaneCreated();
var eh = (ElementHost)((PaneHost)_ctp.ContentControl).Host;
eh.Visible = false;
_ctp.Visible = true;
eh.Visible = true;
}
}
} else if (_ctp != null) {
try {
_ctp.Visible = false;
} catch (COMException) { } // ignore 'The taskpane has been deleted...'
}
}
}
public static void Shutdown() {
try {
_ctp?.Delete();
} catch { }
}
// in actual code this will of course be replaced with a WPF UserControl class :)
static UIElement GetWpfTaskPane() => (UIElement)XamlReader.Parse(
@"<UserControl xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
<DockPanel LastChildFill='false'>
<ComboBox Margin='4'
HorizontalAlignment='Center'
DockPanel.Dock='Bottom'>
<ComboBoxItem Content='Item 1' IsSelected='True' />
<ComboBoxItem Content='Item 2' />
<ComboBoxItem Content='Item 3' />
<ComboBoxItem Content='Item 4' />
<ComboBoxItem Content='Item 5' />
<ComboBoxItem Content='Item 6' />
<ComboBoxItem Content='Item 7' />
<ComboBoxItem Content='Item 8' />
</ComboBox>
</DockPanel>
</UserControl>");
static void EnsurePaneCreated() {
if (_wpfPane == null) {
_wpfPane = GetWpfTaskPane();
}
if (_ctp == null || _ctpParentHwnd != ExcelDnaUtil.WindowHandle) {
if (_ctp != null) {
// if we have a task pane belonging to an inactive window, delete it
try {
((PaneHost)_ctp.ContentControl).Host.Child = null;
_ctp.Delete();
} catch (COMException) { } // ignore 'The taskpane has been deleted...'
}
_ctp = CustomTaskPaneFactory.CreateCustomTaskPane(new PaneHost(), "Task Pane");
try {
((PaneHost)_ctp.ContentControl).Host.Child = _wpfPane;
} catch (InvalidOperationException) {
// happens if the pane is already marked as child to another object
_wpfPane = GetWpfTaskPane();
((PaneHost)_ctp.ContentControl).Host.Child = _wpfPane;
}
_ctpParentHwnd = ExcelDnaUtil.WindowHandle;
_ctp.Width = _paneWidth;
_ctp.VisibleStateChange += p => {
_paneWidth = _ctp.Width;
Globals.NotifyTaskPaneVisibilityChanged();
};
}
}
}
}
Everything works as expected when Excel is maximized, and the ComboBox opens upward. The trouble starts when you position the Excel window such that the ComboBox opens downward, then select Item 2. Instead of selecting that item, the click is stolen by Excel, and sent to the zoom control. And when you select any item outside the Excel window's boundaries, the ComboBox closes and nothing else happens. According to the Add-In Express people, you can solve this by putting the following into PaneHost:
const uint WS_CLIPCHILDREN = 0x02000000;
const uint WS_CLIPSIBLINGS = 0x04000000;
const uint WS_POPUP = 0x80000000;
Forms.CreateParams _createParams = new Forms.CreateParams();
protected override Forms.CreateParams CreateParams {
get {
_createParams = base.CreateParams;
unchecked {
// need to add WS_POPUP because otherwise, Excel steals mouse clicks from controls that are
// drawn outside the normal task pane boundaries - e.g., if a ComboBox drops down over the
// Excel zoom widget, clicking within the ComboBox would go to the zoom widget instead
_createParams.Style = (int)(WS_CLIPCHILDREN | WS_CLIPSIBLINGS | WS_POPUP);
}
return _createParams;
}
}
Now the
ComboBox works, but if you hover over the worksheet and use the scrollwheel, that event gets stolen by the task pane. Clicking on the worksheet makes no difference to this. To get the proper scroll behavior back, you have to click on the taskbar, then back on Excel. Which is something I can do programmatically:
public static void BlinkFocus() {
// Set focus to the taskbar, then back to Excel. Ugly hack, do not use.
SetForegroundWindow(FindWindow("Shell_trayWnd", null));
SetForegroundWindow(ExcelDnaUtil.WindowHandle);
}
[DllImport("user32.dll", SetLastError = true)]
static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
[DllImport("user32.dll", SetLastError = true)]
static extern bool SetForegroundWindow(IntPtr hWnd);
But I'd rather not have to resort to that, since at this point I'm adding hacks atop hacks. There has to be a better way. Has anyone else had to deal with this?