Zip a Folder with Progress in .NET (WPF Example)
Create a ZIP from a folder in .NET while reporting progress to a WPF ProgressBar using ZipArchive and IProgress for smooth UI updates.
ZipFile.CreateFromDirectory is convenient, but it doesn’t give you progress updates.
In desktop apps (especially WPF), it’s often important to show a progress bar while zipping a large folder.
This example builds a ZIP using ZipArchive and reports progress via IProgress<T>.
On the UI side, a simple WPF ViewModel updates a ProgressBar and status text through
INotifyPropertyChanged.
Goal
- Create a ZIP from a folder.
- Show progress (percentage + current file).
- Keep the UI responsive (async + progress callbacks).
- Allow selecting source folder and output ZIP path from the UI.
Zip Helper (ZipArchive + Progress)
The helper enumerates all files under a folder, creates entries in a ZipArchive, and copies file streams into the archive.
After each file is added, it reports progress via IProgress<ZipProgressInfo>.
- Why ZipArchive? It gives full control over how entries are created and when progress is reported.
- Why report per file? It’s simple and usually good enough for UX. (Byte-level progress is possible, but more complex.)
- Relative paths: ZIP entries should use paths relative to the selected folder.
using System.IO.Compression;
public sealed record ZipProgressInfo(
int FilesProcessed,
int TotalFiles,
string CurrentFile)
{
public double Percent => TotalFiles == 0 ? 0 : (FilesProcessed * 100.0 / TotalFiles);
}
public static class ZipHelper
{
public static async Task ZipDirectoryAsync(
string sourceDirectory,
string zipFilePath,
IProgress<ZipProgressInfo>? progress,
CancellationToken ct)
{
if (!Directory.Exists(sourceDirectory))
throw new DirectoryNotFoundException(sourceDirectory);
var files = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories).ToList();
var total = files.Count;
// Ensure output folder exists
var outDir = Path.GetDirectoryName(zipFilePath);
if (!string.IsNullOrWhiteSpace(outDir))
Directory.CreateDirectory(outDir);
// Overwrite if exists
if (File.Exists(zipFilePath))
File.Delete(zipFilePath);
await using var zipStream = new FileStream(zipFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: false);
var processed = 0;
foreach (var filePath in files)
{
ct.ThrowIfCancellationRequested();
var entryName = Path.GetRelativePath(sourceDirectory, filePath)
.Replace('\\', '/'); // ZIP prefers '/'
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
await using (var entryStream = entry.Open())
await using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read))
{
await fileStream.CopyToAsync(entryStream, ct);
}
processed++;
progress?.Report(new ZipProgressInfo(
FilesProcessed: processed,
TotalFiles: total,
CurrentFile: entryName));
}
}
}
WPF ViewModel (Why INotifyPropertyChanged?)
In WPF, controls can bind to ViewModel properties. When a property changes, the UI updates automatically
if the ViewModel raises PropertyChanged. That’s exactly what INotifyPropertyChanged is for.
This ViewModel holds:
SourceFolderandZipPath(selected by the user)ProgressValue(0-100) andStatusText(current file)- Commands for selecting paths and starting/canceling the ZIP process
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using Microsoft.Win32;
public sealed class ZipFolderViewModel : INotifyPropertyChanged
{
private string _sourceFolder = "";
private string _zipPath = "";
private double _progressValue;
private string _statusText = "Ready.";
private bool _isBusy;
private CancellationTokenSource? _cts;
public string SourceFolder
{
get => _sourceFolder;
set { _sourceFolder = value; OnPropertyChanged(); StartZipCommand.RaiseCanExecuteChanged(); }
}
public string ZipPath
{
get => _zipPath;
set { _zipPath = value; OnPropertyChanged(); StartZipCommand.RaiseCanExecuteChanged(); }
}
public double ProgressValue
{
get => _progressValue;
set { _progressValue = value; OnPropertyChanged(); }
}
public string StatusText
{
get => _statusText;
set { _statusText = value; OnPropertyChanged(); }
}
public bool IsBusy
{
get => _isBusy;
set
{
_isBusy = value;
OnPropertyChanged();
SelectFolderCommand.RaiseCanExecuteChanged();
SelectZipPathCommand.RaiseCanExecuteChanged();
StartZipCommand.RaiseCanExecuteChanged();
CancelCommand.RaiseCanExecuteChanged();
}
}
public RelayCommand SelectFolderCommand { get; }
public RelayCommand SelectZipPathCommand { get; }
public AsyncRelayCommand StartZipCommand { get; }
public RelayCommand CancelCommand { get; }
public ZipFolderViewModel()
{
SelectFolderCommand = new RelayCommand(SelectFolder, () => !IsBusy);
SelectZipPathCommand = new RelayCommand(SelectZipPath, () => !IsBusy);
StartZipCommand = new AsyncRelayCommand(StartZipAsync, CanStart);
CancelCommand = new RelayCommand(Cancel, () => IsBusy);
}
private bool CanStart()
=> !IsBusy
&& !string.IsNullOrWhiteSpace(SourceFolder)
&& !string.IsNullOrWhiteSpace(ZipPath);
private void SelectFolder()
{
var dlg = new Microsoft.Win32.OpenFolderDialog
{
Title = "Select a folder",
Multiselect = false
};
bool? ok = dlg.ShowDialog();
if (ok == true)
{
SourceFolder = dlg.FolderName;
}
}
private void SelectZipPath()
{
var dlg = new SaveFileDialog
{
Title = "Save ZIP file as",
Filter = "ZIP files (*.zip)|*.zip",
DefaultExt = "zip",
AddExtension = true
};
if (dlg.ShowDialog() == true)
ZipPath = dlg.FileName;
}
private async Task StartZipAsync()
{
IsBusy = true;
ProgressValue = 0;
StatusText = "Starting...";
_cts = new CancellationTokenSource();
try
{
var progress = new Progress<ZipProgressInfo>(p =>
{
ProgressValue = p.Percent;
StatusText = $"{p.FilesProcessed}/{p.TotalFiles} - {p.CurrentFile}";
});
await ZipHelper.ZipDirectoryAsync(SourceFolder, ZipPath, progress, _cts.Token);
ProgressValue = 100;
StatusText = "Done.";
}
catch (OperationCanceledException)
{
StatusText = "Canceled.";
}
catch (Exception ex)
{
StatusText = $"Error: {ex.Message}";
}
finally
{
_cts?.Dispose();
_cts = null;
IsBusy = false;
}
}
private void Cancel()
{
_cts?.Cancel();
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
Note: Progress<T> posts updates back to the captured UI context when created on the UI thread,
so updating bound properties is safe.
Note: This example uses Microsoft.Win32.OpenFolderDialog, which is available in .NET 8+ for WPF.
If you target an older .NET version, WPF doesn’t provide a built-in folder picker. In that case you can:
-
Use a WinForms folder picker (
System.Windows.Forms.FolderBrowserDialog) inside a WPF app, or - Use a dedicated folder picker library (e.g., Windows API Code Pack / Common File Dialog).
XAML (Buttons + ProgressBar + Bindings)
This view provides:
- Button to select the source folder
- Button to select the output ZIP path
- Start and Cancel buttons
- ProgressBar + status text
<Window x:Class="ZipDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Zip Folder" Height="260" Width="560">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Margin="0,0,10,10"
VerticalAlignment="Center" Text="Source folder:" />
<TextBox Grid.Row="0" Grid.Column="1" Margin="0,0,10,10"
Text="{Binding SourceFolder}" IsReadOnly="True" />
<Button Grid.Row="0" Grid.Column="2" Margin="0,0,0,10"
Content="Select..."
Command="{Binding SelectFolderCommand}" />
<TextBlock Grid.Row="1" Grid.Column="0" Margin="0,0,10,10"
VerticalAlignment="Center" Text="ZIP path:" />
<TextBox Grid.Row="1" Grid.Column="1" Margin="0,0,10,10"
Text="{Binding ZipPath}" IsReadOnly="True" />
<Button Grid.Row="1" Grid.Column="2" Margin="0,0,0,10"
Content="Save as..."
Command="{Binding SelectZipPathCommand}" />
<StackPanel Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3"
Orientation="Horizontal" Margin="0,0,0,10">
<Button Width="110" Margin="0,0,10,0"
Content="Start"
Command="{Binding StartZipCommand}" />
<Button Width="110"
Content="Cancel"
Command="{Binding CancelCommand}" />
</StackPanel>
<ProgressBar Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="3"
Height="16" Minimum="0" Maximum="100"
Value="{Binding ProgressValue}" />
<TextBlock Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="3" Margin="0,8,0,0"
Text="{Binding StatusText}" TextTrimming="CharacterEllipsis" />
</Grid>
</Window>
Where to Set the ViewModel (DataContext)
There are two common ways to connect the View (XAML) to the ViewModel:
- Code-behind (simple for demos): set
DataContextinMainWindowconstructor. - XAML (works when ViewModel has a parameterless constructor): declare it inside
Window.DataContext.
For this example, the simplest approach is code-behind:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new ZipFolderViewModel();
}
}
If you prefer XAML (and the ViewModel can be created without parameters), you can do:
<Window ...
xmlns:local="clr-namespace:ZipDemo">
<Window.DataContext>
<local:ZipFolderViewModel />
</Window.DataContext>
<!-- ... -->
</Window>
Small Command Helpers (RelayCommand / AsyncRelayCommand)
WPF commands keep UI logic out of code-behind. Here’s a minimal implementation:
using System.Windows.Input;
public sealed class RelayCommand(Action execute, Func<bool>? canExecute = null) : ICommand
{
private readonly Action _execute = execute;
private readonly Func<bool> _canExecute = canExecute ?? (() => true);
public bool CanExecute(object? parameter) => _canExecute();
public void Execute(object? parameter) => _execute();
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged()
=> CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public sealed class AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null) : ICommand
{
private readonly Func<Task> _execute = execute;
private readonly Func<bool> _canExecute = canExecute ?? (() => true);
private bool _isRunning;
public bool CanExecute(object? parameter) => !_isRunning && _canExecute();
public async void Execute(object? parameter)
{
if (!CanExecute(parameter)) return;
try
{
_isRunning = true;
RaiseCanExecuteChanged();
await _execute();
}
finally
{
_isRunning = false;
RaiseCanExecuteChanged();
}
}
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged()
=> CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
Common Improvements
- Report progress by bytes (more accurate when file sizes vary a lot).
- Skip locked files and collect warnings instead of failing the whole process.
- Run multiple compress operations in parallel only with care (disk I/O becomes the bottleneck fast).
- Use dependency injection for the ViewModel (instead of newing it in code-behind).
TL;DR
ZipArchiveprovides control needed for progress reporting.IProgress<T>makes progress updates simple and UI-safe in WPF.INotifyPropertyChangedupdates the UI automatically when ViewModel properties change.- A small MVVM setup (commands + bindings) keeps the UI responsive and clean.