Grouping an Array of Shapes - excel

When an array of Shapes is given to a subroutine By Reference, how can these Shapes be grouped, WITHOUT referring to them by their .name strings ?
The code below does not work:
Sub GroupShapes(ByRef ShapeArray() As Shape)
Dim i As Long
Dim IDs() As Variant
ReDim IDs(LBound(ShapeArray) To UBound(ShapeArray))
For i = LBound(ShapeArray) To UBound(ShapeArray)
IDs(i) = ShapeArray(i).ID 'If .ID is changed into .Name then the objects become grouped Later, but they are being referred to by their name strings
Next i
ActiveSheet.Shapes.Range(IDs).Group
End Sub
I can make the code above work, just by changing .ID to .Name, but that is referring to the shapes by their .name strings which is exactly what I am trying to avoid.

As has been noted, you can create a ShapeRange by index. The difficulty is in finding the index of your shape, which isn't the same as the ID property. Additionally, your shape may already be grouped, so it won't necessarily exist at Worksheet.Shapes level
It's possible to have nested shape groups, but I believe these have to be nested from bottom-level up. In other words, I think if you try to sub-group and already grouped shape, an error will be thrown.
I may be missing something obvious, but that suggests we can group the array by finding the Worksheet.Shapes level index of a shape that either is or contains our target shape. And the index could be found by iterating those top-level shapes until the unique ID property matches. It would then be possible to create a ShapeRange on the resulting indexes.
I wonder if something like this would work:
Private Function GroupShapes(ByRef shapeArray() As Shape) As Shape
Dim i As Long, n As Long
Dim ws As Worksheet
Dim sh As Shape
Dim obj As Object
Dim idList As Collection
Dim id As Variant
Dim idArray() As Long
'Create the list of ids for sheet level shapes.
Set idList = New Collection
For i = LBound(shapeArray) To UBound(shapeArray)
Set sh = shapeArray(i)
Do While sh.Child
Set sh = sh.ParentGroup
Loop
On Error Resume Next
idList.Add sh.id, CStr(sh.id)
On Error GoTo 0
Next
If idList.Count <= 1 Then Exit Function
'Define the sheet parent.
Set obj = shapeArray(LBound(shapeArray)).Parent
Do Until TypeOf obj Is Worksheet
Set obj = obj.Parent
Loop
Set ws = obj
'Find the indexes of the shape ids.
ReDim idArray(idList.Count - 1)
n = 0
For Each id In idList
i = 1
For Each sh In ws.Shapes
If id = sh.id Then
idArray(n) = i
Exit For
End If
i = i + 1
Next
n = n + 1
Next
'Group by index.
Set GroupShapes = ws.Shapes.Range(idArray).Group
End Function
The following test seemed to work for me:
Public Sub RunMe()
Dim shapeArray(0 To 3) As Shape
Dim g As Shape
'Create a sample array.
'Note some of these shapes are already grouped so
'wouldnt appear at Sheet.Shapes level.
Set shapeArray(0) = Sheet1.Shapes("Rectangle 1")
Set shapeArray(1) = Sheet1.Shapes("Isosceles Triangle 2")
Set shapeArray(2) = Sheet1.Shapes("Arrow: Right 4")
Set shapeArray(3) = Sheet1.Shapes("Oval 7")
'Group the array.
Set g = GroupShapes(shapeArray)
End Sub

Related

Group Shapes by Shape Object in VBA Excel

I'm having problems in grouping shapes by name with VBA in Excel.
This happens because I have multiple shapes that can have the same name.
The following code can recreate my problem.
You can uncomment line OriginalShape.Name = "MyShape" to see the error.
Sub test()
' Create Original Shape
Dim OriginalShape As Shape
Set OriginalShape = Sheet1.Shapes.AddShape(msoShapeRectangle, 5, 20, 50, 50)
' Rename Shape to simulate my project
' OriginalShape.Name = "MyShape" ' Uncomment line to recreate problem
' Copy and Paste Shape (I believe there is no other way to do this)
OriginalShape.Copy
Sheet1.Paste Sheet1.Range("C2")
' Get Object of Last Pasted Shape
Dim CloneShape As Shape
Set CloneShape = Sheet1.Shapes(Sheet1.Shapes.Count)
' Group Shapes
Dim ShapeGroup As Shape
Set ShapeGroup = Sheet1.Shapes.Range(Array(OriginalShape.Name, CloneShape.Name)).Group
End Sub
I know I also have the possibility to use Shape indexes, like Sheet1.Shapes.Range(Array(1, 2)).Group, but this is doesn't seem a good way either, as I would need to store one more variable for each shape (the shape index) apart from the shape Object.
Is there a way to group shapes some other way, like through Object or ID.
I believe the best would be something like.
Set ShapeGroup = Sheet1.Shapes.Range(Array(OriginalShape, CloneShape)).Group
'OR
Set ShapeGroup = Sheet1.Shapes.Range(Array(OriginalShape.ID, CloneShape.ID)).Group
Like Tim Williams said: the code fails, as the group-array consists of equal names. What you need to do, is adding the index to the name while creating the shapes
This will work:
Sub test()
Const cntShapes As Long = 2
Dim i As Long, shp As Shape, cTarget As Range
Dim arrShapeNames(1 To cntShapes) As Variant
With Sheet1
For i = 1 To cntShapes
Set cTarget = .Cells(1, i) 'adjust this to your needs
Set shp = .Shapes.AddShape(msoShapeRectangle, cTarget.Left, cTarget.Top, 50, 50)
shp.Name = "MyShape." & i 'adding the index to the name makes it unique
arrShapeNames(i) = shp.Name
Next
End With
' Group Shapes
Dim ShapeGroup As Shape
Set ShapeGroup = Sheet1.Shapes.Range(arrShapeNames).Group
End Sub

VBA How to access all ActiveX controls in a worksheet even the ones within groups

I would like to access all ActiveX CheckBoxes and OptionButtons in a worksheet. I tried to make a loop for that, but my loop is not able to get all of them.
After checking the names of the ones that I can't get, I found that they are groupped (by selecting them, right click, group). How to access all of my controls in a worksheet even if they are groupped ?
here is the code that I am using now and that allows me to get the controls that are directly in the worksheet not groupped but it is not allowing me to get groupped controls.
I am reading sheets filled by users, and some of the users have groupped the controls and others haven't, that's why I can't really know in advance if controls are groupped or not, so i need to access all of them in current worksheet that my code reads.
'ws is my worksheet
Dim obj As OLEObject
For Each obj In ws.OLEObjects
Debug.Print obj.Name
Next obj
End If
I think it's an important task to reach all OLE Objects, so I created the below code in a modular fashion and tested on some example objects:
Option Explicit
Public Sub Example()
Dim colOleObjects As Collection: Set colOleObjects = CollectOleObjectsOnWorksheet(ActiveSheet)
Dim colCheckboxesAndOptionboxes As Collection: Set colCheckboxesAndOptionboxes = FilterOleObjectsByType(colOleObjects, Array("Forms.CheckBox.1", "Forms.OptionButton.1"))
Dim varItem As Variant: For Each varItem In colCheckboxesAndOptionboxes
Dim shpItem As Shape: Set shpItem = varItem
Debug.Print shpItem.Name
Next varItem
End Sub
Public Function FilterOleObjectsByType(colSource As Collection, varTypes As Variant) As Collection
Dim colDestination As Collection: Set colDestination = New Collection
Dim varElement As Variant: For Each varElement In colSource
Dim shpElement As Shape: Set shpElement = varElement
Dim i As Long: For i = LBound(varTypes) To UBound(varTypes)
If shpElement.OLEFormat.progID = varTypes(i) Then
colDestination.Add shpElement
Exit For
End If
Next i
Next varElement
Set FilterOleObjectsByType = colDestination
End Function
Public Function CollectOleObjectsOnWorksheet(ewsTarget As Worksheet) As Collection
Dim colResult As Collection: Set colResult = New Collection
Dim varChild As Variant: For Each varChild In ewsTarget.Shapes
Dim shpChild As Shape: Set shpChild = varChild
Dim colChild As Collection: Set colChild = CollectOleObjectsOfShape(shpChild)
CollectionAddElements colResult, colChild
Next varChild
Set CollectOleObjectsOnWorksheet = colResult
End Function
Public Function CollectOleObjectsOfShape(shpTarget As Shape) As Collection
Dim colResult As Collection: Set colResult = New Collection
Select Case shpTarget.Type
Case MsoShapeType.msoEmbeddedOLEObject, MsoShapeType.msoOLEControlObject
colResult.Add shpTarget
Case MsoShapeType.msoGroup
Dim varChild As Variant: For Each varChild In shpTarget.GroupItems
Dim shpChild As Shape: Set shpChild = varChild
Dim colChild As Collection: Set colChild = CollectOleObjectsOfShape(shpChild)
CollectionAddElements colResult, colChild
Next varChild
End Select
Set CollectOleObjectsOfShape = colResult
End Function
Public Sub CollectionAddElements(colTarget As Collection, colSource As Collection)
Dim varElement As Variant: For Each varElement In colSource
colTarget.Add varElement
Next varElement
End Sub
Basically, CollectOleObjectsOnWorksheet returns a collection of all OleObjects on the Worksheet given as a parameter building on the functionality of recursively enumerating OleObjects provided by CollectOleObjectsOfShape. CollectionAddElements is just a helper function to create the union of two Collections. In my code, Example retrieves the Collection of OleObjects on the ActiveSheet, filters it to include only CheckBoxes and OptionBoxes by calling FilterOleObjectsByType then it prints the name of each. However, once you have this collection, you can do anything with it.
I think the advantage of my solution is that the enumeration of objects is decoupled from the actual task you want to do with them. You just have to include the three functions somewhere in the code and call CollectOleObjectsOnWorksheet from your part of the code.
Update:
I modified the code: (1) OleObjects may have msoOLEControlObject, (2) I added a Function to filter the objects retrieved, so that they include only CheckBoxes and OptionBoxes.
I would not recommend to Group and Ungroup the Shapes because you can access these objects with my code without modifying the original document. However, if you need to do so, you can call the .Ungroup Method of Shape to ungroup them, or the .Group Method of ShapeRange. The latter is a bit trickier because you have to call it on an object returned by Worksheet.Shapes.Range(Array("ShapeName1", "ShapeName2")) or Shape.GroupItems.Range(Array("ShapeName1", "ShapeName2")).
To get all ActiveX Objects, even when put into a group, start by using the Shapes-Collection rather than the OLEObjects-Collection.
You can check for Type = msoOLEControlObject (12) of the shape so that you list only OLEObjects. Groups have the Type msoGroup (6) and have a Collection GroupItems that holds all Shapes within that group.
You could write a recursive routine. See the following code to write all OLEObjects.
Update: The code now creates a Dictionary containing all CheckBoxex and RadioButtons plus their value. Note that you need a reference to the Scripting Library.
Sub ListAllObjects()
Dim ListOfOptions as Dictionary
Set ListOfOptions = New Dictionary
ListObjects ActiveSheet.Shapes, ListOfOptions
End Sub
Sub ListObjects(objArr, ListOfOptions)
Dim sh As Shape
For Each sh In objArr
If sh.Type = msoOLEControlObject Then
' Debug.Print sh.Name; sh.Type; TypeName(sh.OLEFormat.Object.Object)
' Found OptionButton or CheckBox: Add it to Dictionary.
If TypeName(sh.OLEFormat.Object.Object) = "OptionButton" Or TypeName(sh.OLEFormat.Object.Object) = "CheckBox" Then
ListOfOptions.Add sh.Name, sh.OLEFormat.Object.Object.Value
End If
End If
If sh.Type = msoGroup Then
ListObjects sh.GroupItems, ListOfOptions
End If
Next sh
End Sub
To Ungroup:
Dim sh As Shape
For Each sh In ActiveSheet.Shapes
If sh.Type = msoGroup Then sh.Ungroup
Next sh

vb.net Excel : Issue with getting Chart CategoryNames

I need, for some reason, the category names of my chart. Here's what I got so far:
xlWorkbook = xlApp.ActiveWorkbook
Dim wsnat As Excel.Chart = TryCast(xlWorkbook.ActiveChart, Excel.Chart)
If Not wsnat Is Nothing Then
Dim axxxis As Excel.Axis = DirectCast(wsnat.Axes(Excel.XlAxisType.xlCategory, Excel.XlAxisGroup.xlPrimary), Excel.Axis)
Dim areyoukiddingme As Object = axxxis.CategoryNames
Dim arr As Array = DirectCast(areyoukiddingme, Array)
For q As Integer = 0 To arr.GetUpperBound(0)
Debug.Print(arr(q).ToString) ' HERE, the array 'arr' has two things which are EMPTY!
Next
End If
My problem is, that the array (arry) has the correct amount of EMPTY objects. If I do the whole thing in VBA, it works as expected. But it does not for VB.net. Any clues?
Here's the code in VBA:
Sub test()
Dim chrt As Chart
Set chrt = ActiveChart
Dim names As Variant
names = chrt.Axes(xlCategory, xlPrimary).CategoryNames
End Sub
This sub nicely outputs the category names of my chart!

Excel VBA: How to add all of the current shapes on a worksheet to a ShapeRange?

Somewhat of a VBA newbie here.
It is clear to me how to create a ShapeRange using individual or multiple Shape objects:
Dim sht As Worksheet
Set sht = MySht
'
'*Add some shapes*
'
Dim shprng As ShapeRange
Set shprng = sht.Shapes.Range(Array(1,2,3))
Is there a way to add ALL of the currently existing shapes on a worksheet to shprng? In other words, is there a method to return a ShapeRange from a Shapes object...? Something like this:
Set shprng = sht.Shapes.Range.SelectAll '<--- Does not work: Type Mismatch
Set shprng = sht.Shapes '<--- Same error
Set shprng = sht.Shapes.Range '<--- Error: Argument not optional
Thanks!
If you want to create a ShapeRange by selecting all the shapes on a sheet, you would first select them then get the ShapeRange from the Selection object.
sht.Shapes.Range.SelectAll
Set shprng = Selection.ShapeRange
I usually prefer not to use the Selection object in VBA because it tends to be flaky and can cause errors in weird situations. I think a better way to do this is to build an array of Shape indexes and get the ShapeRange using this array.
Dim shape_index As Variant
Dim i As Long
ReDim shape_index(1 To sht.Shapes.Count)
For i = 1 To UBound(shape_index)
shape_index(i) = i
Next
Set shprng = sht.Shapes.Range(shape_index)
In my Office 365, the code of TmDean did not work. It was necessary explicitly declare of variable as dinamic array.
Dim shape_index() As Variant 'Dim shape_index() as Long
Dim i As Long
ReDim shape_index(1 To sht.Shapes.Count)
For i = 1 To UBound(shape_index)
shape_index(i) = i
Next
Set shprng = sht.Shapes.Range(shape_index)

Excel VBA: How to obtain a reference to a Shape from the ChartObject

I am trying to obtain a reference to a Shape in a Worksheet, corresponding to a ChartObject. I found no certain way of doing this. The only approximation, by trial-and-error and simply tested in a few cases, is assuming that the ZOrder of a ChartObject is the same as the Index of the corresponding Shape:
Function chobj2shape(ByRef cho As ChartObject) As Shape
' It appears that the ZOrder of a ChartObject is the same as the Index of
' the corresponding Shape, which in turn appears to be the same as its ZOrderPosition
Dim zo As Long
Dim ws As Worksheet
Dim shc As Shapes
Dim sh As Shape
zo = cho.ZOrder
Set ws = cho.Parent
Set shc = ws.Shapes
Set sh = shc.Item(zo)
Set chobj2shape = sh
'Set sh = Nothing
End Function
(a slight excess of defined variables is used for debugging purposes).
Is there any more certain way of doing this?
Any identifier used for picking the correct Shape should be unique. The name is not necessarily unique (see https://stackoverflow.com/questions/19153331/duplicated-excel-chart-has-the-same-name-name-as-the-original-instead-of-increm), so it is not guaranteed to work. The Index/ZOrderPosition is just a guess, at least satisfying the requirement of uniqueness.
Edit: see answer by #Andres in Excel VBA: Index = ZOrderPosition in a Shapes collection?. It is clear that the ZOrder of a ChartObject is not equal to the Index of either the ChartObject or the corresponding Shape (and I have verified this).
But it appears that ZOrder is equal to ZOrderPosition of the corresponding Shape. This was verified with dump_chartobjects:
Sub dump_chartobjects()
' Dump information on all ChartObjects in a Worksheet.
Dim coc As ChartObjects
Set coc = ActiveSheet.ChartObjects
Dim cho As ChartObject
Dim ich As Long
For ich = 1 To coc.Count
Dim msg As String
Set cho = coc(ich)
With cho
msg = "ChartObject '" & .name & "'" _
& ", type name: " & TypeName(cho) & ", at: " & .TopLeftCell.Address _
& ", index: " & ich & ", .Index: " & .Index _
& ", ZOrder: " & .ZOrder
'& ", hyperlink: " & .Hyperlink
End With
Debug.Print msg
Dim ish As Long
ish = choidx2shpidx(ich, coc.Parent)
Next ich
End Sub
Function choidx2shpidx(coidx As Long, ws As Worksheet) As Long
Dim cozo As Long
Dim coc As ChartObjects
Dim co As ChartObject
Set coc = ws.ChartObjects
Set co = coc(coidx)
cozo = co.ZOrder
choidx2shpidx = zo2idx_shp(cozo, ws)
Dim con As String, shn As String
Dim sh As Shape
Set sh = ws.Shapes(choidx2shpidx)
con = co.name
shn = sh.name
Dim cox As Double, coy As Double
Dim cow As Double, coh As Double
Dim shx As Double, shy As Double
Dim shw As Double, shh As Double
cox = co.Left
coy = co.top
cow = co.Width
coh = co.Height
shx = sh.Left
shy = sh.top
shw = sh.Width
shh = sh.Height
If ((con <> shn) Or (cox <> shx) Or (coy <> shy) Or (cow <> shw) Or (coh <> shh)) Then
Dim msg As String
msg = "ChartObject: '" & con & "', Shape: '" & shn & "'"
'Debug.Print msg
MsgBox msg
choidx2shpidx = -1
End If
End Function
Function zo2idx_shp(zo As Long, ws As Worksheet) As Long
Dim ish As Long
Dim shc As Shapes
Dim sh As Shape
Set shc = ws.Shapes
For ish = 1 To shc.Count
Set sh = shc(ish)
If (sh.ZOrderPosition = zo) Then
zo2idx_shp = ish
Exit Function
End If
Next ish
zo2idx_shp = -1
End Function
After losing hours in a similar issue, I found a couple of concepts related to referencing shapes in excel, but none satisfies me 100%. For accessing a shape you have 4 pure methods:
Shape.Name : Is FAST, but NOT RELIABLE. The name of the shape could be used to get a reference of a shape but provided you don't have duplicated names. Code: ActiveSheet.Shapes("Shape1")
Shape.ZOrderPosition : Very FAST, but NOT RELIABLE. The ZOrder of the shape could be used to get a reference of a shape, because is the same as the index of the shape in the shapes collection. But provided you don't have group of shapes that breaks previous rule (See: https://stackoverflow.com/a/19163848/2843348). Code: ActiveSheet.Shapes(ZOrderFromOneShape)
Set shpRef=Shape: FAST, RELIABLE, but NOT PERSISTENT. I try to use this always I can, specially when I create a new shape. Moreover, if I have to iterate on the new shapes later one I try to keep the object reference inside a collection. However not Persistent, that means if you stop and run you VBA code again to will loose all the references and collection. Code: Set shp = NewShape, or you can add it to a collection: coll.add NewShape for loop it later on.
Shape.ID : RELIABLE, PERSISTENT, but not directly supported! The ID of the shape is very reliable (don't change and cannot be duplicates IDs in a Sheet). However, there is no direct VBA function to get a shape back knowing its ID. The only way is to loop thorough all shapes until the ID match the ID you was looking for, but this can be very SLOW!.
Code:
Function FindShapeByID(ws as excel.worksheet, ID as long) as Excel.Shape
dim i as long
set FindShapeByID = nothing 'Not found...
for i = 1 to ws.shapes.count
if ws.shapes(i).ID = ID then
set FindShapeByID = ws.shapes(i) 'Return the shape object
exit function
end if
next i
End Function
Note 1: If you want to access this function several times, you can improve it by using a cache of Shape IDs. That way you will make the loop only one time.
Note 2: If you move a shape from one sheet to other, the ID of the shape will change!
By mixing and using above knowledge, I have concluded in two main approaches:
FIRST APPROACH
FASTEST BUT VOLATILE: (same as point#3) Try to keep the reference in a object as longer you can. When I have to iterate trough a bunch of shapes later on, I save the references inside a collection and I avoid to use other secondary reference like the name, ZOrder or ID.
For example:
dim col as new Collection
dim shp as Excel.Shape
'' <- Insert the code here, where you create your shape or chart
col.add shp1
'' <- Make other stuffs
for each shp in col
'' <- make something with the shape in this loop!
next shp
The problem of course is that the collection and reference are not permanent. You will loose them when you stop and restart the vba code!
SECOND APPROACH
PERSISTENT: My solution is to save the name and the ID of the shape for later reference. Why? Having the name I can access the shape very fast most of the time. Just in case I found a duplicated name I make the slow loop searching the ID. How can I know if there is a name duplicated? Very simple, just check the ID of the first name search, and if they don't match you have to suppose is duplicated.
Here the code:
Function findShapeByNameAndID(ws As Excel.Worksheet, name As String, ID As Long) As Shape
Dim sh As Excel.Shape
Set findShapeByNameAndID = Nothing 'Means not found
On Error GoTo fastexit
Set sh = ws.Shapes(name)
'Now check if the ID matches
If sh.ID = ID Then
'Found! This should be the usual case!
Set findShapeByNameAndID = sh
Else
'Ups, not the right shape. We ha to make a loop!
Dim i As Long
For i = 1 To ws.Shapes.Count
If ws.Shapes(i).ID = ID Then
'Found! This should be the usual case!
Set findShapeByNameAndID = ws.Shapes(i)
End If
Next i
End If
fastexit:
Set sh = Nothing
End Function
Hope this helps you!
Note 1: Is you want to search shapes that maybe inside groups, then the function is more complicated.
Note 2: The ZOrder looks nice, but cannot find it useful. When I tried to take advantage of it, there was always a missing part...
#TimWilliams is almost right (in his comment). However, there are some situation where Tim's idea could get confusing results.
I think the following code will be more appropriate and correct.
Sub qTest()
Dim cho As ChartObject
Set cho = ActiveSheet.ChartObjects(1)
Dim SH As Shape
Set SH = cho.ShapeRange.Item(1)
SH.Select 'here Shape will be selected..
Debug.Print TypeName(SH) '...which we can check here
End Sub

Resources