Crear un ZIP de una carpeta con progreso en .NET (ejemplo WPF)
Genera un ZIP desde una carpeta en .NET y actualiza una ProgressBar en WPF usando ZipArchive e IProgress para una UI fluida.
ZipFile.CreateFromDirectory es práctico, pero no ofrece actualizaciones de progreso.
En aplicaciones de escritorio (especialmente WPF), suele ser importante mostrar una barra de progreso
mientras se comprime una carpeta grande.
En este ejemplo, se crea el ZIP con ZipArchive y se reporta el progreso mediante IProgress<T>.
Del lado de la UI, un ViewModel WPF simple actualiza una ProgressBar y un texto de estado gracias a
INotifyPropertyChanged.
Objetivo
- Crear un ZIP a partir de una carpeta.
- Mostrar el progreso (porcentaje + archivo actual).
- Mantener la UI responsiva (async + callbacks de progreso).
- Permitir seleccionar la carpeta fuente y la ruta de salida del ZIP desde la UI.
Zip Helper (ZipArchive + Progress)
El helper recorre todos los archivos de la carpeta, crea entradas en ZipArchive y copia los streams
de los archivos dentro de la nueva estructura ZIP. Después de agregar cada archivo, reporta el progreso vía
IProgress<ZipProgressInfo>.
- ¿Por qué ZipArchive? Da control total sobre cómo se crean las entradas y cuándo se reporta el progreso.
- ¿Por qué progreso por archivo? Es simple y suele ser suficiente para UX. (El progreso por bytes es posible, pero más complejo.)
- Rutas relativas: Las entradas ZIP deben crearse con rutas relativas a la carpeta seleccionada.
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;
// Asegurar que exista la carpeta de salida
var outDir = Path.GetDirectoryName(zipFilePath);
if (!string.IsNullOrWhiteSpace(outDir))
Directory.CreateDirectory(outDir);
// Sobrescribir si ya existe
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 prefiere '/'
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));
}
}
}
ViewModel WPF (¿Por qué INotifyPropertyChanged?)
En WPF, los controles pueden enlazarse (binding) a propiedades del ViewModel. Cuando una propiedad cambia,
la UI se actualiza automáticamente si el ViewModel dispara el evento PropertyChanged.
Para eso se usa INotifyPropertyChanged.
Este ViewModel contiene:
SourceFolderyZipPath(seleccionados por el usuario)ProgressValue(0-100) yStatusText(archivo actual)- Comandos para seleccionar rutas y para iniciar/cancelar el proceso de ZIP
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 = "Listo.";
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 = "Seleccionar carpeta",
Multiselect = false
};
bool? ok = dlg.ShowDialog();
if (ok == true)
{
SourceFolder = dlg.FolderName;
}
}
private void SelectZipPath()
{
var dlg = new SaveFileDialog
{
Title = "Guardar archivo ZIP",
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 = "Iniciando...";
_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 = "Terminado.";
}
catch (OperationCanceledException)
{
StatusText = "Cancelado.";
}
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));
}
Nota: Progress<T> envía las actualizaciones de vuelta al contexto UI capturado (si se crea en el hilo de UI).
Por eso es seguro actualizar propiedades enlazadas.
Nota: Este ejemplo usa Microsoft.Win32.OpenFolderDialog, disponible para WPF a partir de .NET 8+.
Si apuntas a una versión anterior, WPF no incluye un selector de carpetas integrado. En ese caso:
-
Puedes usar un selector WinForms (
System.Windows.Forms.FolderBrowserDialog) dentro de una app WPF, o - Usar una librería dedicada (por ejemplo Windows API Code Pack / Common File Dialog).
XAML (Botones + ProgressBar + binding)
Esta vista ofrece:
- Botón para seleccionar la carpeta fuente
- Botón para seleccionar la ruta de salida del ZIP
- Botones Iniciar y Cancelar
- ProgressBar + texto de estado
<Window x:Class="ZipDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Comprimir carpeta" 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="Carpeta origen:" />
<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="Seleccionar..."
Command="{Binding SelectFolderCommand}" />
<TextBlock Grid.Row="1" Grid.Column="0" Margin="0,0,10,10"
VerticalAlignment="Center" Text="Ruta ZIP:" />
<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="Guardar..."
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="Iniciar"
Command="{Binding StartZipCommand}" />
<Button Width="110"
Content="Cancelar"
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>
Conectar el ViewModel (DataContext)
Hay dos formas comunes de conectar la View (XAML) con el ViewModel:
- Code-behind (lo más simple para una demo): asignar
DataContexten el constructor deMainWindow. - XAML (si el ViewModel tiene constructor sin parámetros): declararlo dentro de
Window.DataContext.
En este ejemplo, lo más simple es code-behind:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new ZipFolderViewModel();
}
}
Alternativa en XAML (si el ViewModel puede crearse sin parámetros):
<Window ...
xmlns:local="clr-namespace:ZipDemo">
<Window.DataContext>
<local:ZipFolderViewModel />
</Window.DataContext>
<!-- ... -->
</Window>
Helpers pequeños para comandos (RelayCommand / AsyncRelayCommand)
Los comandos WPF evitan poner lógica de UI en code-behind. Aquí tienes una implementación mínima:
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);
}
Mejoras comunes
- Reportar el progreso por bytes (más preciso si los tamaños de archivo varían mucho).
- Omitir archivos bloqueados y recolectar warnings (en lugar de fallar todo el proceso).
- Ejecutar compresión en paralelo con cuidado (el I/O de disco se vuelve bottleneck rápidamente).
- Crear el ViewModel con dependency injection (en lugar de hacer
newen code-behind).
TL;DR
ZipArchiveofrece el control necesario para reportar progreso.IProgress<T>hace simples y seguras las actualizaciones de progreso en WPF.INotifyPropertyChangedactualiza la UI automáticamente cuando cambian propiedades del ViewModel.- Un setup MVVM pequeño (comandos + bindings) mantiene la UI fluida y limpia.