Excel VBA Macro delete row infinite loop - vba

I have a VBA macro that is intended to format a specified range of cells automatically for the user and it does so correctly. However when the user tries to delete a row in the specified range it triggers the error message I built in as an infinite loop.
The code looks like this:
Private Sub Worksheet_Change(ByVal Target As Range)
Dim rTimeCells As Range
Set rTimeCells = Range("D2:G15")
Application.EnableEvents = False
If Not Application.Intersect(rTimeCells, Range(Target.Address)) Is Nothing Then
Call Time_Format(Range(Target.Address))
End If
Application.EnableEvents = True
End Sub
Private Sub Time_Format(rCells As Range)
Dim RegEXFormat1 As Object
...
Dim RegEXFormatX As Object
Set RegEXFormat1 = CreateObject("VBScript.RegExp")
...
Set RegEXFormatX = CreateObject("VBScript.RegExp")
RegEXFormat1.Global = True
...
RegEXFormatX.Global = True
RegEXFormat1.IgnoreCase = True
...
RegEXFormatX.IgnoreCase = True
RegEXFormat1.Pattern = "..."
...
RegEXFormatX.Pattern = "..."
For Each cell In rCells
Dim sValue As String
Dim sMonth As String
Dim sDay As String
Dim sYear As String
Dim bCorrectFormat As Boolean
bCorrectFormat = True
sValue = CStr(cell.Text)
If (RegEXFormat1.test(sValue)) Then
...
ElseIF(RegEXFormatX.test(sValue) Then
...
Else
If (sValue = "" Or sValue = "<Input Date>") Then
Else
MsgBox ("Please Input the date in correct numeric format")
cell.value = "<Input Date>"
End If
Next
The user insists that they need the ability to delete rows of data without sending this macro into a loop. How can I modify what I have here to allow for this occurrence?
(I clearly modified the code and left a lot out that I dont feel was necessary here and to stop my post from being pages and pages long.)

Instead of this:
If Not Application.Intersect(rTimeCells, Range(Target.Address)) Is Nothing Then
Call Time_Format(Range(Target.Address))
End If
you probably want to limit the range you pass to Time_Format using something like this:
Dim rngTime as Range
Set rngTime = Application.Intersect(rTimeCells, Target)
If Not rngTime Is Nothing Then
Call Time_Format(rngTime)
End If
Note: Range(Target.Address) is equivalent to Target

Related

Changing value of a cell based on the value of two cells - VBA Excel

I am trying to add some automation to a spreadsheet by changing the value of cells in one column based on the value in that column and one other. I have got the code below so far. If I use .text the code runs through fine but makes no changes to the values of the cells. If I use .value I get this error message:
Run-time error '13: Type mismatch
Please could someone advise on what I am doing wrong here.
Sub change_orrtime_4()
Application.ScreenUpdating = False
Application.Calculation = xlCalculationManual
'For Each employee In Range("Timesheet_RawData[Employee]")
Dim employee As Range
Dim datefield As Range
Dim tbl As ListObject
Dim tRows As Long
Dim tCols As Long
Dim i As Long
Set tbl = Sheets("Timesheet Data").ListObjects("Timesheet_RawData")
With tbl.DataBodyRange
tRows = .Rows.Count
' tCols = .Colummns.Count
End With
With Sheets("Timesheet Data")
Set employee = Sheets("Timesheet Data").Range("Timesheet_RawData[Employee]")
Set datefield = Sheets("Timesheet Data").Range("Timesheet_RawData[Date]")
End With
With Sheets("Timesheet Data")
For i = 2 To tRows
If employee.Value = "Some Name" And datefield.Value = "1" Then ' type mismatch doesnt occur with .text but then nothing works
employee.Value = "Some Name_SomeTeam"
End If
Next i
End With
Application.ScreenUpdating = True
Application.Calculation = xlCalculationAutomatic
End Sub
you're setting employee (and datefield, too) to multiple cells ranges, therefore you can't access it Value property, while you can access it Text property that would return a text if all cells share that same text or otherwise a Null
so you have to point at the specific cell in that range, like:
employee(i).Value
finally you could refactor your code a little as follows:
Sub change_orrtime_4()
Dim employee As Range
Dim datefield As Range
Dim tRows As Long
Dim tCols As Long
Dim i As Long
With Sheets("Timesheet Data")
With .ListObjects("Timesheet_RawData")
With .DataBodyRange
tRows = .Rows.Count
' tCols = .Colummns.Count
End With
Set employee = .ListColumns("Employee").DataBodyRange
Set datefield = .ListColumns("Date").DataBodyRange
End With
For i = 1 To tRows
If employee(i).Value = "Some Name" And datefield(i).Value = "1" Then employee(i).Value = "Some Name_SomeTeam"
Next i
End With
End Sub

Fill array formula for different cells by entering formula in one cell

I am now trying to achieve something like the query function in Google Sheets. Obviously in this GIF, someone has already done that. I wonder how they could do that in Excel / VBA.
My specific question is: in VBA, how to fill other cells' formulas by entering a formula in a specific cell? (replicate the function used in this GIF and not using VBA + advanced filter)
Enter a formula in cell A3
Press CTRL + SHIFT + ENTER
Receive results
This is what I got so far:
The code in a standard module:
Sub run_sql_sub(sql)
On Error Resume Next
Set cn = CreateObject("ADODB.Connection")
Set rs = CreateObject("ADODB.Recordset")
With cn
.ConnectionString = "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" & _
This Workbook.FullName _
& ";Extended Properties=""Excel 12.0 Xml;HDR=YES;IMEX=1"";"
.Open
End With
rs.Open sql, cn
Application.ScreenUpdating = False
ActiveSheet.Range("A1:XFD1048576").ClearContents
For intColIndex = 0 To rs.Fields.Count - 1
Range("A1").Offset(0, intColIndex).Value = rs.Fields(intColIndex).Name
Next
Range("A2").CopyFromRecordset rs
Application.ScreenUpdating = True
rs.Close: cn.Close: Set rs = Nothing: Set cn = Nothing
End Sub
And this code is in activesheet's module:
Private Sub Worksheet_Change(ByVal Target As Range)
Dim KeyCells As Range
Set KeyCells = ActiveSheet.Range("A1")
If Not Application.Intersect(KeyCells, Range(Target.Address)) _
Is Nothing Then
If InStr(KeyCells.Value2, "mi_sql") > 0 Then
sql = Right(KeyCells.Value2, Len(KeyCells.Value2) - Len("mi_sql "))
run_sql_sub sql
End If
End If
End Sub
Update 08.04.2019: found a solution
' Code in standard Module
Public collectCal As Collection
Private ccal As CallerCal
Sub subResizeKQ(caller As CallerInfo)
On Error Resume Next
Application.EnableEvents = False
If caller.Id <> "" Then
Application.Range(caller.Id).ClearContents
Application.Range(caller.Id).Resize(caller.rows, caller.cols).FormulaArray = caller.FomulaText
End If
Application.EnableEvents = True
End Sub
Function ResizeKQ(value As Variant) As Variant
If ccal Is Nothing Then Set ccal = New CallerCal
If collectCal Is Nothing Then Set collectCal = New Collection
Dim caller As New CallerInfo
Dim rows As Long, cols As Long
Dim arr As Variant
arr = value
rows = UBound(arr, 1) - LBound(arr, 1) + 1
cols = UBound(arr, 2) - LBound(arr, 2) + 1
Dim rgcaller As Range
Set rgcaller = Application.caller
caller.Id = rgcaller.Address(True, True, xlA1, True, True)
caller.rows = rgcaller.rows.Count
caller.cols = rgcaller.Columns.Count
caller.FomulaText = rgcaller.Resize(1, 1).Formula
If caller.rows <> rows Or caller.cols <> cols Then
caller.rows = rows
caller.cols = cols
collectCal.Add caller, caller.Id
End If
ResizeKQ = arr
End Function
Function fRandArray(numRow As Long, numCol As Long) As Variant
Application.Volatile True
ReDim arr(1 To numRow, 1 To numCol)
For i = 1 To numRow
For j = 1 To numCol
arr(i, j) = Rnd
Next
Next
fRandArray = ResizeKQ(arr)
End Function
'--------------------------------------------------------------------------
' code in Class Module name CallerCal
Private WithEvents AppEx As Application
Private Sub AppEx_SheetCalculate(ByVal Sh As Object)
Dim caller As CallerInfo
If collectCal Is Nothing Then Exit Sub
For Each caller In collectCal
subResizeKQ caller
collectCal.Remove caller.Id
Set caller = Nothing
Next
Set collectCal = Nothing
End Sub
Private Sub Class_Initialize()
Set AppEx = Application
End Sub
Private Sub Class_Terminate()
Set AppEx = Nothing
End Sub
'--------------------------------------------------------------------------
' code in Class Module name CallerInfo
Public rows As Long
Public cols As Long
Public Id As String
Public FomulaText As String
To test it, go to Excel Sheet, enter the following test formula in A1:
=fRandArray(10,10)
P.S: If anyone is using Excel 365 Insider Program, Microsoft has published this kind of formula called Dynamic Array Function:
https://support.office.com/en-ie/article/dynamic-arrays-and-spilled-array-behavior-205c6b06-03ba-4151-89a1-87a7eb36e531
I agree with the other comments - MS doesn't seem to provide a way to do this natively, and any way of doing it directly would probably involve some Excel-breaking memory manipulation.
However...
I suggest taking your method one step further and generalizing it
Copy and paste this class into a text file, then import it into VBA (which allows Attribute VB_PreDeclaredID = True and Attribute Item.VB_UserMemId = 0):
RangeEdit
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "RangeEdit"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
Private colRanges As Collection
Private colValues As Collection
Private Sub Class_Initialize()
Set colRanges = New Collection
Set colValues = New Collection
End Sub
Public Property Let Item(rng_or_address As Variant, value As Variant)
Attribute Item.VB_UserMemId = 0
colRanges.Add rng_or_address
colValues.Add value
End Property
Public Sub flush(sh As Worksheet)
Application.EnableEvents = False
While colRanges.Count > 0
If TypeName(colRanges(1)) = "Range" Then
colRanges(1).value = colValues(1)
ElseIf TypeName(colRanges(1)) = "String" Then
sh.Range(colRanges(1)).value = colValues(1)
End If
colRanges.Remove 1
colValues.Remove 1
Wend
Application.EnableEvents = True
End Sub
Make your Workbook_SheetChange method the following:
Private Sub Workbook_SheetChange(ByVal sh As Object, ByVal Target As Range)
RangeEdit.flush sh
End Sub
Now you can create a UDF that modifies other cells. The way it works is it queues up all the modifications you make and only runs them after the cell loses focus. The syntax allows you to treat it almost like the regular Range function. You can run it either with an address string or with an actual range (though you might want to add an error if it's not either one of those).
Here is a quick example UDF that can be run from an Excel cell formula:
Public Function MyUDF()
RangeEdit("A1") = 4
RangeEdit("B1") = 6
RangeEdit("C4") = "Hello everyone!"
Dim r As Range
Set r = Range("B12")
RangeEdit(r) = "This is a test of using a range variable"
End Function
For your specific case, I would replace
For intColIndex = 0 To rs.Fields.Count - 1
Range("A1").Offset(0, intColIndex).Value = rs.Fields(intColIndex).Name
Next
with
For intColIndex = 0 To rs.Fields.Count - 1
RangeEdit(Range("A1").Offset(0, intColIndex)) = rs.Fields(intColIndex).Name
Next
And to copy the recordset I would define the following function (it assumes that the recordset cursor is set to the first record. if you Move it previously you might want to have rs.MoveFirst in there):
Public Sub FormulaSafeRecordsetCopy(rs As ADODB.Recordset, rng As Range)
Dim intColIndex As Long
Dim intRowIndex As Long
While Not rs.EOF
For intColIndex = 0 To rs.Fields.Count - 1
RangeEdit(rng.Offset(intRowIndex, intColIndex)) = rs.Fields(intColIndex).value
Next
rs.MoveNext
intRowIndex = intRowIndex + 1
Wend
End Sub

Coding for In/Out Tracking of Tools with no repeating and text always getting added into and not deleted text input by barcode scanner

I am trying to make a code in Microsoft Excel Where it puts a text into a cell when another cell is filled in.
What I am looking for is that when cell A for example is filled cell C is filled in with OUT. Then when cell A is filled in again on the next line or another line below it cell C on the same line as cell A is filled in with IN.
We would like to utilize a barcode scanner for checking the tools in and out. I already figured out how to get the barcode to scan into column A
I would like this process to be repeated over and over again.
It's supposed to be a tracking sheet for when tools get taken out and get put back into stock. The text is going to constantly be added and nothing deleted. We want to utilize a barcode scanner to check tools in and out. The employees scan their barcode indicating them then they scan the tool indicating what tool they are taking. Then when they come back they scan their barcode again and then they scan the tool back into inventory. Of course just having this simple setup will lead to a mess of whether the tool is in or out and who used it last since we have a bunch of employees taking tools IN and OUT constantly. That way we can be sure of who used what tool last and whether it's IN or OUT.
Below I have the coding that I need for the time stamp.
Private Sub Worksheet_Change(ByVal Target As Range)
Dim B As Range, AC As Range, t As Range
Set B = Range("B:B")
Set AC = Range("A:A")
Set t = Target
If Intersect(t, AC) Is Nothing Then Exit Sub
Application.EnableEvents = False
Range("B" & t.Row).Value = Now
Application.EnableEvents = True
End Sub
It sounds like a very contrived example for asking the question "In VBA, how do I fill an Excel cell with a specific string?"
The answer to that question is:
myRange.Value = "<myString>"
Anyway, this is how I would try to tackle your problem:
Private Sub Worksheet_Change(ByVal Target As Range)
Dim rngChange As Range
Dim rngIntersect As Range
Dim xlCell As Range
Dim inOut As String
Set rngChange = Range("A:A")
Set rngIntersect = Intersect(Target, rngChange)
If Not rngIntersect Is Nothing Then
Application.EnableEvents = False
For Each xlCell In rngIntersect
If xlCell.Value = "" Then
inOut = "OUT"
Else
inOut = "IN"
End If
xlCell.Offset(0, 1).Value = Now
xlCell.Offset(0, 2).Value = inOut
Next xlCell
Application.EnableEvents = True
End If
End Sub
Edit:
In response to the asker's comments, the following modified code should address the problem:
Private Sub Worksheet_Change(ByVal Target As Range)
Dim rngChange As Range
Dim rngIntersect As Range
Dim inOut As String
Set rngChange = Range("A:A")
Set rngIntersect = Intersect(Target, rngChange)
If Not rngIntersect Is Nothing Then
Application.EnableEvents = False
If rngIntersect.Row = 1 Then
inOut = "OUT"
ElseIf rngIntersect.Offset(-1, 2).Value = "OUT" Then
inOut = "IN"
Else
inOut = "OUT"
End If
rngIntersect.Offset(0, 1).Value = Now
rngIntersect.Offset(0, 2).Value = inOut
Application.EnableEvents = True
End If
End Sub
Edit2:
Use this to loop backwards through your log to determine the previous bookiung status for a specific id:
Option Explicit
Private Sub Worksheet_Change(ByVal Target As Range)
Dim rngChange As Range
Dim rngIntersect As Range
Dim xlCell As Range
Dim scanId As String
Dim inOutOld As String
Dim inOut As String
Set rngChange = Range("A:A")
Set rngIntersect = Intersect(Target, rngChange)
If Not rngIntersect Is Nothing Then
Application.EnableEvents = False
scanId = rngIntersect.Value
Set xlCell = rngIntersect
If rngIntersect.Row = 1 Then
inOut = "OUT"
Else
Do Until xlCell.Row = 1
Set xlCell = xlCell.Offset(-1, 0)
If xlCell.Value = scanId Then
inOutOld = xlCell.Offset(0, 2).Value
Exit Do
End If
Loop
End If
If inOutOld = "IN" Then
inOut = "OUT"
Else
inOut = "IN"
End If
rngIntersect.Offset(0, 1).Value = Now
rngIntersect.Offset(0, 2).Value = inOut
Application.EnableEvents = True
End If
End Sub
Instead of using VBA, you could do this with a worksheet 'IF()' formula.
=IF(A3="","","OUT")
=IF(A4="","","IN")
To break it down, this means that if cell A3 = nothing ("") then put nothing ("") in cell C3, but if there is something in cell A3, then put "OUT".
Place the first formula in cell C3 and the second one in C4. If the user of the tool inputs their initials/name in cell A3 then cell C3 will say OUT. It's not until the user comes back and returns the tool and enters their initials/name in cell A4 that cell C4 will say IN.
Hope this simple, non-VBA, example helps!

Cannot use named range when it is empty

I have a named range lstVendors that refers to: =OFFSET(Data!$W$2,0,0,COUNTA(Data!$W$2:$W$400),1). I want this range to be populated when the workbook opens. I have the following code for this:
Private Sub Workbook_Open()
Application.WindowState = xlMaximized
Dim rslt()
Dim i As Integer
Dim n As Integer
Dim startRng As Range
Dim DropDown1 As DropDown
ThisWorkbook.Sheets("Dashboard").Shapes("TextBox 6").Visible = False
' Range("lstVendors").Offset(0, 0).Value = "Please Select..."
' Set DropDown1 = ThisWorkbook.Sheets("Dashboard").DropDowns("Drop Down 1")
' DropDown1.Value = 1
On Error Resume Next
If Not IsError(Range("lstVendors")) Then
Range("lstVendors").ClearContents
End If
On Error GoTo 0
rslt = Application.Run("SQLite_Query", "path/to/my/sqlite", "SELECT PROGRAM_ID FROM VENDOR;")
Set startRng = Range("lstVendors")
i = 0
For n = 2 To UBound(rslt)
Range("lstVendors").Offset(i, 0).Value = rslt(n)(0)
i = i + 1
Next n
End Sub
It errors on the Set startRng = Range("lstVendors"). I know this is because there's nothing in the range when I'm trying to set it, because if I put one entry into the named range, the set works, however, I need it populated by the sqlite query on each open as the data changes.
Any suggestions much appreciated.
Try this. You have a dynamic range that doesn't evaluate after you clear the contents. To avoid this, there are probably several ways, but easy to simply hardcode the startRange variable so that it always points to Data!$W$2 address, which is (or rather, will become) the first cell in your lstVendors range.
Private Sub Workbook_Open()
Dim rslt()
Dim i As Integer
Dim n As Integer
Dim startRng As Range
Dim DropDown1 As DropDown
Dim rngList As Range
'// Define your startRange -- always will be the first cell in your named range "lstVendors"
' hardcode the address because the dynamic range may not evalaute.
Set startRange = Sheets("Data").Range("W2")
'// Empty th lstVendors range if it exists/filled
On Error Resume Next
Range("lstVendors").Clear
On Error GoTo 0
'// Run your SQL query
rslt = Application.Run("SQLite_Query", "path/to/my/sqlite", "SELECT PROGRAM_ID FROM VENDOR;")
i = 0
'// Print results to the Worksheet, beginning in the startRange cell
For n = 2 To UBound(rslt)
'Increment from the startRange cell
startRange.Offset(i, 0).Value = rslt(n)(0)
i = i + 1
'Verify that "lstVendors" is being populated
Debug.Print Range("lstVendors").Address
Next n
End Sub
Thanks for the suggestions. Here is what I ended up doing in order to get around my problem. It involves adding something I didn't specify would be ok in my original question, so David's answer is great if what I did isn't an option. I first populated the first two cells in my named range with "Please Select..." and "All". In Sub Workbook_Open() we do this:
Private Sub Workbook_Open()
Application.WindowState = xlMaximized
Dim rslt()
Dim i As Integer
Dim n As Integer
Dim startRng As Range
Dim DropDown1 As DropDown
' Disable our not found message
ThisWorkbook.Sheets("Dashboard").Shapes("TextBox 6").Visible = False
' Set our start range to our named range
Set startRng = Range("lstVendors")
' Grab all vendor names
rslt = Application.Run("SQLite_Query", "path/to/my/sqlite", "SELECT PROGRAM_ID FROM VENDOR;")
' Print result. Skip first two rows as constants "Please Select..." and "All" are populated there
i = 2
For n = 2 To UBound(rslt)
startRng.Offset(i, 0).Value = rslt(n)(0)
i = i + 1
Next n
End Sub
Then we will create Sub Workbook_BeforeClose:
Private Sub Workbook_BeforeClose(Cancel As Boolean)
' Disable the save changes dialog. This workbook will be locked up for display only. No need to confuse the user.
Application.DisplayAlerts = False
' Clear everything below the "Please Select..." and "All" cells in the named range
On Error Resume Next
Range("lstVendors").Offset(2, 0).ClearContents
On Error GoTo 0
' Save the changes to the named range
ThisWorkbook.Save
Application.DisplayAlerts = True
End Sub
This information is going to populate a drop down, so having Please Select and All hardcoded into the named range is acceptable for me. If that stipulation doesn't work for someone else looking at this in the future, please use David's suggestion! Thanks again!

How do I get the old value of a changed cell in Excel VBA?

I'm detecting changes in the values of certain cells in an Excel spreadsheet like this...
Private Sub Worksheet_Change(ByVal Target As Range)
Dim cell As Range
Dim old_value As String
Dim new_value As String
For Each cell In Target
If Not (Intersect(cell, Range("cell_of_interest")) Is Nothing) Then
new_value = cell.Value
old_value = ' what here?
Call DoFoo (old_value, new_value)
End If
Next cell
End Sub
Assuming this isn't too bad a way of coding this, how do I get the value of the cell before the change?
try this
declare a variable say
Dim oval
and in the SelectionChange Event
Public Sub Worksheet_SelectionChange(ByVal Target As Range)
oval = Target.Value
End Sub
and in your Worksheet_Change event set
old_value = oval
You can use an event on the cell change to fire a macro that does the following:
vNew = Range("cellChanged").value
Application.EnableEvents = False
Application.Undo
vOld = Range("cellChanged").value
Range("cellChanged").value = vNew
Application.EnableEvents = True
I had to do it too. I found the solution from "Chris R" really good, but thought it could be more compatible in not adding any references. Chris, you talked about using Collection. So here is another solution using Collection. And it's not that slow, in my case. Also, with this solution, in adding the event "_SelectionChange", it's always working (no need of workbook_open).
Dim OldValues As New Collection
Private Sub Worksheet_SelectionChange(ByVal Target As Range)
'Copy old values
Set OldValues = Nothing
Dim c As Range
For Each c In Target
OldValues.Add c.Value, c.Address
Next c
End Sub
Private Sub Worksheet_Change(ByVal Target As Range)
On Local Error Resume Next ' To avoid error if the old value of the cell address you're looking for has not been copied
Dim c As Range
For Each c In Target
Debug.Print "New value of " & c.Address & " is " & c.Value & "; old value was " & OldValues(c.Address)
Next c
'Copy old values (in case you made any changes in previous lines of code)
Set OldValues = Nothing
For Each c In Target
OldValues.Add c.Value, c.Address
Next c
End Sub
I have an alternative solution for you. You could create a hidden worksheet to maintain the old values for your range of interest.
Private Sub Workbook_Open()
Dim hiddenSheet As Worksheet
Set hiddenSheet = Me.Worksheets.Add
hiddenSheet.Visible = xlSheetVeryHidden
hiddenSheet.Name = "HiddenSheet"
'Change Sheet1 to whatever sheet you're working with
Sheet1.UsedRange.Copy ThisWorkbook.Worksheets("HiddenSheet").Range(Sheet1.UsedRange.Address)
End Sub
Delete it when the workbook is closed...
Private Sub Workbook_BeforeClose(Cancel As Boolean)
Application.DisplayAlerts = False
Me.Worksheets("HiddenSheet").Delete
Application.DisplayAlerts = True
End Sub
And modify your Worksheet_Change event like so...
For Each cell In Target
If Not (Intersect(cell, Range("cell_of_interest")) Is Nothing) Then
new_value = cell.Value
' here's your "old" value...
old_value = ThisWorkbook.Worksheets("HiddenSheet").Range(cell.Address).Value
Call DoFoo(old_value, new_value)
End If
Next cell
' Update your "old" values...
ThisWorkbook.Worksheets("HiddenSheet").UsedRange.Clear
Me.UsedRange.Copy ThisWorkbook.Worksheets("HiddenSheet").Range(Me.UsedRange.Address)
Here's a way I've used in the past. Please note that you have to add a reference to the Microsoft Scripting Runtime so you can use the Dictionary object - if you don't want to add that reference you can do this with Collections but they're slower and there's no elegant way to check .Exists (you have to trap the error).
Dim OldVals As New Dictionary
Private Sub Worksheet_Change(ByVal Target As Range)
Dim cell As Range
For Each cell In Target
If OldVals.Exists(cell.Address) Then
Debug.Print "New value of " & cell.Address & " is " & cell.Value & "; old value was " & OldVals(cell.Address)
Else
Debug.Print "No old value for " + cell.Address
End If
OldVals(cell.Address) = cell.Value
Next
End Sub
Like any similar method, this has its problems - first off, it won't know the "old" value until the value has actually been changed. To fix this you'd need to trap the Open event on the workbook and go through Sheet.UsedRange populating OldVals. Also, it will lose all its data if you reset the VBA project by stopping the debugger or some such.
an idea ...
write these in the ThisWorkbook module
close and open the workbook
Public LastCell As Range
Private Sub Workbook_Open()
Set LastCell = ActiveCell
End Sub
Private Sub Workbook_SheetSelectionChange(ByVal Sh As Object, ByVal Target As Range)
Set oa = LastCell.Comment
If Not oa Is Nothing Then
LastCell.Comment.Delete
End If
Target.AddComment Target.Address
Target.Comment.Visible = True
Set LastCell = ActiveCell
End Sub
Place the following in the CODE MODULE of a WORKSHEET to track the last value for every cell in the used range:
Option Explicit
Private r As Range
Private Const d = "||"
Public Function ValueLast(r As Range)
On Error Resume Next
ValueLast = Split(r.ID, d)(1)
End Function
Private Sub Worksheet_Activate()
For Each r In Me.UsedRange: Record r: Next
End Sub
Private Sub Worksheet_Change(ByVal Target As Range)
For Each r In Target: Record r: Next
End Sub
Private Sub Record(r)
r.ID = r.Value & d & Split(r.ID, d)(0)
End Sub
And that's it.
This solution uses the obscure and almost never used
Range.ID property, which allows the old values to persist when the workbook is saved and closed.
At any time you can get at the old value of
a cell and it will indeed be different than a new current value:
With Sheet1
MsgBox .[a1].Value
MsgBox .ValueLast(.[a1])
End With
I've expanded a bit on Matt Roy's solution which is awesome by the way. What I did is handle situations when the user selects the whole row/column, so the macro only record the intersection between selection and ".UsedRange", and also handled situations where selection is not a range (for buttons, shapes, pivot tables)
Sub trackChanges_loadOldValues_toCollection(ByVal Target As Range)
'LOADS SELECTION AND VALUES INTO THE COLLECTION collOldValues
If isErrorHandlingOff = False Then: On Error GoTo endWithError
Dim RngI As Range, newTarget As Range, arrValues, arrFormulas, arrAddress
'DON'T RECORD WHEN SELECTING BUTTONS OR SHAPES, ONLY FOR RANGES
If TypeName(Target) <> "Range" Then: Exit Sub
'RESET OLD VALUES COLLECITON
Set collOldValues = Nothing
'ONLY RECORD CELLS IN USED RANGE, TO AVOID ISSUES WHEN SELECTING WHOLE ROW
Set newTarget = Intersect(Target, Target.Parent.UsedRange)
'newTarget.Select
If Not newTarget Is Nothing Then
For Each RngI In newTarget
'ADD TO COLLECTION
'ITEM, KEY
collOldValues.add Array(RngI.value, RngI.formula), RngI.Address
Next RngI
End If
done:
Exit Sub
endWithError:
DisplayError Err, "trackChanges_loadOldValues_toCollection", Erl
End Sub
try this, it will not work for the first selection, then it will work nice :)
Private Sub Worksheet_SelectionChange(ByVal Target As Range)
On Error GoTo 10
If Target.Count > 1 Then GoTo 10
Target.Value = lastcel(Target.Value)
10
End Sub
Function lastcel(lC_vAl As String) As String
Static vlu
lastcel = vlu
vlu = lC_vAl
End Function
I had a need to capture and compare old values to the new values entered into a complex scheduling spreadsheet. I needed a general solution which worked even when the user changed many rows at the same time. The solution implemented a CLASS and a COLLECTION of that class.
The class: oldValue
Private pVal As Variant
Private pAdr As String
Public Property Get Adr() As String
Adr = pAdr
End Property
Public Property Let Adr(Value As String)
pAdr = Value
End Property
Public Property Get Val() As Variant
Val = pVal
End Property
Public Property Let Val(Value As Variant)
pVal = Value
End Property
There are three sheets in which i track cells. Each sheet gets its own collection as a global variable in the module named ProjectPlan as follows:
Public prepColl As Collection
Public preColl As Collection
Public postColl As Collection
Public migrColl As Collection
The InitDictionaries SUB is called out of worksheet.open to establish the collections.
Sub InitDictionaries()
Set prepColl = New Collection
Set preColl = New Collection
Set postColl = New Collection
Set migrColl = New Collection
End Sub
There are three modules used to manage each collection of oldValue objects they are Add, Exists, and Value.
Public Sub Add(ByRef rColl As Collection, ByVal sAdr As String, ByVal sVal As Variant)
Dim oval As oldValue
Set oval = New oldValue
oval.Adr = sAdr
oval.Val = sVal
rColl.Add oval, sAdr
End Sub
Public Function Exists(ByRef rColl As Collection, ByVal sAdr As String) As Boolean
Dim oReq As oldValue
On Error Resume Next
Set oReq = rColl(sAdr)
On Error GoTo 0
If oReq Is Nothing Then
Exists = False
Else
Exists = True
End If
End Function
Public Function Value(ByRef rColl As Collection, ByVal sAdr) As Variant
Dim oReq As oldValue
If Exists(rColl, sAdr) Then
Set oReq = rColl(sAdr)
Value = oReq.Val
Else
Value = ""
End If
End Function
The heavy lifting is done in the Worksheet_SelectionChange callback. One of the four is shown below. The only difference is the collection used in the ADD and EXIST calls.
Private Sub Worksheet_SelectionChange(ByVal Target As Range)
Dim mode As Range
Set mode = Worksheets("schedule").Range("PlanExecFlag")
If mode.Value = 2 Then
Dim c As Range
For Each c In Target
If Not ProjectPlan.Exists(prepColl, c.Address) Then
Call ProjectPlan.Add(prepColl, c.Address, c.Value)
End If
Next c
End If
End Sub
THe VALUE call is called out of code executed from the Worksheet_Change Callback for example. I need to assign the correct collection based on the sheet name:
Dim rColl As Collection
If sheetName = "Preparations" Then
Set rColl = prepColl
ElseIf sheetName = "Pre-Tasks" Then
Set rColl = preColl
ElseIf sheetName = "Migr-Tasks" Then
Set rColl = migrColl
ElseIf sheetName = "post-Tasks" Then
Set rColl = postColl
Else
End If
and then i am free to compute compare the some current value to the original value.
If Exists(rColl, Cell.Offset(0, 0).Address) Then
tsk_delay = Cell.Offset(0, 0).Value - Value(rColl, Cell.Offset(0, 0).Address)
Else
tsk_delay = 0
End If
Mark
Let's first see how to detect and save the value of a single cell of interest. Suppose Worksheets(1).Range("B1") is your cell of interest. In a normal module, use this:
Option Explicit
Public StorageArray(0 to 1) As Variant
' Declare a module-level variable, which will not lose its scope as
' long as the codes are running, thus performing as a storage place.
' This is a one-dimensional array.
' The first element stores the "old value", and
' the second element stores the "new value"
Sub SaveToStorageArray()
' ACTION
StorageArray(0) = StorageArray(1)
' Transfer the previous new value to the "old value"
StorageArray(1) = Worksheets(1).Range("B1").value
' Store the latest new value in Range("B1") to the "new value"
' OUTPUT DEMONSTRATION (Optional)
' Results are presented in the Immediate Window.
Debug.Print "Old value:" & vbTab & StorageArray(0)
Debug.Print "New value:" & vbTab & StorageArray(1) & vbCrLf
End Sub
Then in the module of Worksheets(1):
Option Explicit
Private HasBeenActivatedBefore as Boolean
' Boolean variables have the default value of False.
' This is a module-level variable, which will not lose its scope as
' long as the codes are running.
Private Sub Worksheet_Activate()
If HasBeenActivatedBefore = False then
' If the Worksheet has not been activated before, initialize the
' StorageArray as follows.
StorageArray(1) = Me.Range("B1")
' When the Worksheets(1) is activated, store the current value
' of Range("B1") to the "new value", before the
' Worksheet_Change event occurs.
HasBeenActivatedBefore = True
' Set this parameter to True, so that the contents
' of this if block won't be evaluated again. Therefore,
' the initialization process above will only be executed
' once.
End If
End Sub
Private Sub Worksheet_Change(ByVal Target As Range)
If Not Intersect(Target, Me.Range("B1")) Is Nothing then
Call SaveToStorageArray
' Only perform the transfer of old and new values when
' the cell of interest is being changed.
End If
End Sub
This will capture the change of the Worksheets(1).Range("B1"), whether the change is due to the user actively selecting that cell on the Worksheet and changing the value, or due to other VBA codes that change the value of Worksheets(1).Range("B1").
Since we have declared the variable StorageArray as public, you can reference its latest value in other modules in the same VBA project.
To expand our scope to the detection and saving the values of multiple cells of interest, you need to:
Declare the StorageArray as a two-dimensional array, with the number of rows equal to the number of cells you are monitoring.
Modify the Sub SaveToStorageArray procedure to a more general Sub SaveToStorageArray(TargetSingleCell as Range) and change the
relevant codes.
Modify the Private Sub Worksheet_Change procedure to accommodate the monitoring of those multiple cells.
Appendix:
For more information on the lifetime of variables, please refer to: https://msdn.microsoft.com/en-us/library/office/gg278427.aspx
I needed this feature and I did not like all the solutions above after trying most as they are either
Slow
Have complex implications like using application.undo.
Do not capture if they were not selected
Do not captures values if they were not changed before
Too complex
Well I thought very hard about it and I completed a solution for a full UNDO,REDO history.
To capture the old value it is actually very easy and very fast.
My solution is to capture all values once the user open the sheet is open into a variable and it gets updated after each change. this variable will be used to check the old value of the cell. In the solutions above all of them used for loop. Actually there is way easier method.
To capture all the values I used this simple command
SheetStore = sh.UsedRange.Formula
Yeah, just that, Actually excel will return an array if the range is a multiple cells so we do not need to use FOR EACH command and it is very fast
The following sub is the full code which should be called in Workbook_SheetActivate. Another sub should be created to capture the changes. Like, I have a sub called "catchChanges" that runs on Workbook_SheetChange. It will capture the changes then save them on another a change history sheet. then runs UpdateCache to update the cache with the new values
' should be added at the top of the module
Private SheetStore() As Variant
Private SheetStoreName As String ' I use this variable to make sure that the changes I captures are in the same active sheet to prevent overwrite
Sub UpdateCache(sh As Object)
If sh.Name = ActiveSheet.Name Then ' update values only if the changed values are in the activesheet
SheetStoreName = sh.Name
ReDim SheetStore(1 To sh.UsedRange.Rows.count, 1 To sh.UsedRange.Columns.count) ' update the dimension of the array to match used range
SheetStore = sh.UsedRange.Formula
End If
End Sub
now to get the old value it is very easy as the array have the same address of cells
examples if we want cell D12 we can use the following
SheetStore(row_number,column_number)
'example
return = SheetStore(12,4)
' or the following showing how I used it.
set cell = activecell ' the cell that we want to find the old value for
newValue = cell.value ' you can ignore this line, it is just a demonstration
oldValue = SheetStore(cell.Row, cell.Column)
these are snippet explaining the method, I hope everyone like it
In response to Matt Roy answer, I found this option a great response, although I couldn't post as such with my current rating. :(
However, while taking the opportunity to post my thoughts on his response, I thought I would take the opportunity to include a small modification. Just compare code to see.
So thanks to Matt Roy for bringing this code to our attention, and Chris.R for posting original code.
Dim OldValues As New Collection
Private Sub Worksheet_SelectionChange(ByVal Target As Range)
'>> Prevent user from multiple selection before any changes:
If Selection.Cells.Count > 1 Then
MsgBox "Sorry, multiple selections are not allowed.", vbCritical
ActiveCell.Select
Exit Sub
End If
'Copy old values
Set OldValues = Nothing
Dim c As Range
For Each c In Target
OldValues.Add c.Value, c.Address
Next c
End Sub
Private Sub Worksheet_Change(ByVal Target As Range)
On Error Resume Next
On Local Error Resume Next ' To avoid error if the old value of the cell address you're looking for has not been copied
Dim c As Range
For Each c In Target
If OldValues(c.Address) <> "" And c.Value <> "" Then 'both Oldvalue and NewValue are Not Empty
Debug.Print "New value of " & c.Address & " is " & c.Value & "; old value was " & OldValues(c.Address)
ElseIf OldValues(c.Address) = "" And c.Value = "" Then 'both Oldvalue and NewValue are Empty
Debug.Print "New value of " & c.Address & " is Empty " & c.Value & "; old value is Empty" & OldValues(c.Address)
ElseIf OldValues(c.Address) <> "" And c.Value = "" Then 'Oldvalue is Empty and NewValue is Not Empty
Debug.Print "New value of " & c.Address & " is Empty" & c.Value & "; old value was " & OldValues(c.Address)
ElseIf OldValues(c.Address) = "" And c.Value <> "" Then 'Oldvalue is Not Empty and NewValue is Empty
Debug.Print "New value of " & c.Address & " is " & c.Value & "; old value is Empty" & OldValues(c.Address)
End If
Next c
'Copy old values (in case you made any changes in previous lines of code)
Set OldValues = Nothing
For Each c In Target
OldValues.Add c.Value, c.Address
Next c
I have the same problem like you and luckily I have read the solution from this link:
http://access-excel.tips/value-before-worksheet-change/
Dim oldValue As Variant
Private Sub Worksheet_SelectionChange(ByVal Target As Range)
oldValue = Target.Value
End Sub
Private Sub Worksheet_Change(ByVal Target As Range)
'do something with oldValue...
End Sub
Note: you must place oldValue variable as a global variable so all subclasses can use it.
Private Sub Worksheet_Change(ByVal Target As Range)
vNEW = Target.Value
aNEW = Target.Address
Application.EnableEvents = False
Application.Undo
vOLD = Target.Value
Target.Value = vNEW
Application.EnableEvents = True
End Sub
Using Static will solve your problem (with some other stuff to initialize old_value properly:
Private Sub Worksheet_Change(ByVal Target As Range)
Static old_value As String
Dim inited as Boolean 'Used to detect first call and fill old_value
Dim new_value As String
If Not Intersect(cell, Range("cell_of_interest")) Is Nothing Then
new_value = Range("cell_of_interest").Value
If Not inited Then
inited = True
Else
Call DoFoo (old_value, new_value)
End If
old_value = new_value
Next cell
End Sub
In workbook code, force call of Worksheet_change to fill old_value:
Private Sub Private Sub Workbook_Open()
SheetX.Worksheet_Change SheetX.Range("cell_of_interest")
End Sub
Note, however, that ANY solution based in VBA variables (including dictionary and another more sophisticate methods) will fail if you stop (Reset) running code (eg. while creating new macros, debugging some code, ...). To avoid such, consider using alternative storage methods (hidden worksheet, for example).
I have read this old post, and I would like to provide another solution.
The problem with running Application.Undo is that Woksheet_Change runs again. We have the same problem when we restore.
To avoid that, I use a piece of code to avoid the second steps through Worksheet_Change.
Before we begin, we must create a Boolean static variable BlnAlreadyBeenHere, to tell Excel not to run Worksheet_Change again
Here you can see it:
Private Sub Worksheet_Change(ByVal Target As Range)
Static blnAlreadyBeenHere As Boolean
'This piece avoid to execute Worksheet_Change again
If blnAlreadyBeenHere Then
blnAlreadyBeenHere = False
Exit Sub
End If
'Now, we will store the old and new value
Dim vOldValue As Variant
Dim vNewValue As Variant
'To store new value
vNewValue = Target.Value
'Undo to retrieve old value
'To avoid new Worksheet_Change execution
blnAlreadyBeenHere = True
Application.Undo
'To store old value
vOldValue = Target.Value
'To rewrite new value
'To avoid new Worksheet_Change execution agein
blnAlreadyBeenHere = True
Target.Value = vNewValue
'Done! I've two vaules stored
Debug.Print vOldValue, vNewValue
End Sub
The advantage of this method is that it is not necessary to run Worksheet_SelectionChange.
If we want the routine to work from another module, we just have to take the declaration of the variable blnAlreadyBeenHere out of the routine, and declare it with Dim.
Same operation with vOldValue and vNewValue, in the header of a module
Dim blnAlreadyBeenHere As Boolean
Dim vOldValue As Variant
Dim vNewValue As Variant
Just a thought, but Have you tried using application.undo
This will set the values back again. You can then simply read the original value. It should not be too difficult to store the new values first, so you change them back again if you like.