Loading...

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


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>.


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:


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:


<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:

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


TL;DR

  • ZipArchive provides control needed for progress reporting.
  • IProgress<T> makes progress updates simple and UI-safe in WPF.
  • INotifyPropertyChanged updates the UI automatically when ViewModel properties change.
  • A small MVVM setup (commands + bindings) keeps the UI responsive and clean.