在这里插入图片描述

案例概述

本案例展示如何创建功能完整的项目时间线组件,用于展示事件的时间顺序和发展过程。时间线是展示历史事件、项目进度、版本更新、里程碑等信息的有效方式。通过将事件按时间顺序排列,用户可以清晰地了解整个发展过程和关键节点。

时间线在企业应用中广泛应用于项目管理、工作流展示、版本管理、用户活动记录等场景。一个完整的时间线系统需要处理事件排序、状态指示、响应式布局、性能优化等问题。此外,还应支持过滤、搜索、详情展示、数据导出等功能,以满足不同的业务需求。

在 PC 端应用中,时间线需要充分利用屏幕空间,支持键盘导航、鼠标交互、无障碍访问等特性,确保用户体验的一致性和高效性。

核心概念

1. 时间线结构与组成

时间线由以下主要元素组成:

  • 竖直线条(Timeline Line):连接各事件的视觉线索,通常为竖直方向,表示时间流向。线条颜色可根据事件状态变化,如完成事件为绿色,进行中的事件为蓝色,待做事件为灰色。

  • 事件标记点(Event Marker):通常为圆形或其他几何形状,标记事件在时间线上的位置。标记点的大小、颜色和图标可表示事件的重要性、类型和状态。

  • 事件卡片(Event Card):展示事件的详细信息,包括日期、标题、描述、参与者等。卡片可以左右交替排列,也可以全部在一侧。

  • 时间标签(Time Label):显示事件发生的具体时间,可以是日期、时间戳或相对时间(如"2小时前")。

2. 事件数据模型

一个完整的事件数据模型应包含以下字段:

class TimelineEvent {
  final String id;           // 事件唯一标识
  final DateTime dateTime;   // 事件发生时间
  final String title;        // 事件标题
  final String description;  // 事件描述
  final String status;       // 事件状态(completed, in_progress, pending)
  final int priority;        // 优先级(1-5)
  final String category;     // 事件分类
  final String? icon;        // 事件图标
  final String? author;      // 事件发起人
  final List<String>? tags;  // 事件标签
  final Map<String, dynamic>? metadata; // 额外元数据
}

3. 视觉设计原则

  • 颜色编码:使用不同颜色表示事件状态和优先级,提高信息识别效率
  • 空间布局:合理利用屏幕空间,在 PC 端可采用左右交替或双列布局
  • 视觉层次:通过大小、粗细、颜色等视觉属性区分重要信息
  • 响应式适配:在不同屏幕尺寸下自动调整布局和样式
  • 交互反馈:提供悬停、点击等交互反馈,增强用户体验

代码详解

1. 基础时间线实现

创建一个基础的时间线需要使用 ListView.builder 来动态构建事件列表,并通过 RowColumn 组合来实现竖直线条和事件标记的布局。以下是一个完整的基础时间线实现:

class TimelineWidget extends StatefulWidget {
  final List<TimelineEvent> events;
  
  const TimelineWidget({required this.events});
  
  
  State<TimelineWidget> createState() => _TimelineWidgetState();
}

class _TimelineWidgetState extends State<TimelineWidget> {
  
  Widget build(BuildContext context) {
    return ListView.builder(
      padding: EdgeInsets.symmetric(vertical: 16, horizontal: 24),
      itemCount: widget.events.length,
      itemBuilder: (context, index) {
        final event = widget.events[index];
        final isLast = index == widget.events.length - 1;
        final isFirst = index == 0;
        
        return Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 时间线竖直线条和标记点
            Column(
              children: [
                // 上方线条(第一个事件不显示)
                if (!isFirst)
                  Container(
                    width: 2,
                    height: 30,
                    color: _getLineColor(widget.events[index - 1].status),
                  ),
                
                // 事件标记点
                Container(
                  width: 40,
                  height: 40,
                  decoration: BoxDecoration(
                    color: _getMarkerColor(event.status),
                    shape: BoxShape.circle,
                    border: Border.all(color: Colors.white, width: 3),
                    boxShadow: [
                      BoxShadow(
                        color: Colors.black.withOpacity(0.1),
                        blurRadius: 4,
                        offset: Offset(0, 2),
                      ),
                    ],
                  ),
                  child: Center(
                    child: Icon(
                      _getEventIcon(event.category),
                      color: Colors.white,
                      size: 20,
                    ),
                  ),
                ),
                
                // 下方线条(最后一个事件不显示)
                if (!isLast)
                  Container(
                    width: 2,
                    height: 30,
                    color: _getLineColor(event.status),
                  ),
              ],
            ),
            
            SizedBox(width: 24),
            
            // 事件卡片
            Expanded(
              child: _buildEventCard(event, index),
            ),
          ],
        );
      },
    );
  }
  
  Color _getMarkerColor(String status) {
    switch (status) {
      case 'completed':
        return Colors.green;
      case 'in_progress':
        return Colors.blue;
      case 'pending':
        return Colors.grey;
      default:
        return Colors.blue;
    }
  }
  
  Color _getLineColor(String status) {
    switch (status) {
      case 'completed':
        return Colors.green.shade200;
      case 'in_progress':
        return Colors.blue.shade200;
      case 'pending':
        return Colors.grey.shade200;
      default:
        return Colors.grey.shade200;
    }
  }
  
  IconData _getEventIcon(String category) {
    switch (category) {
      case 'milestone':
        return Icons.flag;
      case 'release':
        return Icons.rocket;
      case 'bug_fix':
        return Icons.bug_report;
      case 'feature':
        return Icons.lightbulb;
      default:
        return Icons.circle;
    }
  }
  
  Widget _buildEventCard(TimelineEvent event, int index) {
    return Card(
      elevation: 2,
      margin: EdgeInsets.symmetric(vertical: 8),
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 时间和状态
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  _formatDateTime(event.dateTime),
                  style: TextStyle(
                    fontSize: 12,
                    color: Colors.grey.shade600,
                    fontWeight: FontWeight.w500,
                  ),
                ),
                _buildStatusBadge(event.status),
              ],
            ),
            
            SizedBox(height: 8),
            
            // 标题
            Text(
              event.title,
              style: TextStyle(
                fontSize: 16,
                fontWeight: FontWeight.bold,
                color: Colors.black87,
              ),
            ),
            
            SizedBox(height: 8),
            
            // 描述
            Text(
              event.description,
              style: TextStyle(
                fontSize: 14,
                color: Colors.grey.shade700,
              ),
              maxLines: 3,
              overflow: TextOverflow.ellipsis,
            ),
            
            if (event.tags != null && event.tags!.isNotEmpty) ...[
              SizedBox(height: 12),
              Wrap(
                spacing: 8,
                children: event.tags!
                    .map((tag) => Chip(
                      label: Text(tag, style: TextStyle(fontSize: 12)),
                      backgroundColor: Colors.blue.shade50,
                      labelStyle: TextStyle(color: Colors.blue.shade700),
                    ))
                    .toList(),
              ),
            ],
          ],
        ),
      ),
    );
  }
  
  Widget _buildStatusBadge(String status) {
    final statusMap = {
      'completed': ('已完成', Colors.green),
      'in_progress': ('进行中', Colors.blue),
      'pending': ('待做', Colors.grey),
    };
    
    final (label, color) = statusMap[status] ?? ('未知', Colors.grey);
    
    return Container(
      padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
      decoration: BoxDecoration(
        color: color.withOpacity(0.1),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: color, width: 0.5),
      ),
      child: Text(
        label,
        style: TextStyle(
          fontSize: 12,
          color: color,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }
  
  String _formatDateTime(DateTime dateTime) {
    return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} '
        '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
  }
}

这个实现展示了时间线的基本结构,包括竖直线条、事件标记点和事件卡片。每个事件都有颜色编码的状态指示和详细信息展示。

2. 响应式时间线设计

在 PC 端应用中,需要根据屏幕宽度调整时间线的布局。以下是一个支持响应式设计的时间线实现:

class ResponsiveTimelineWidget extends StatefulWidget {
  final List<TimelineEvent> events;
  
  const ResponsiveTimelineWidget({required this.events});
  
  
  State<ResponsiveTimelineWidget> createState() => _ResponsiveTimelineWidgetState();
}

class _ResponsiveTimelineWidgetState extends State<ResponsiveTimelineWidget> {
  
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    
    // 根据屏幕宽度选择不同的布局
    if (screenWidth < 600) {
      return _buildMobileTimeline();
    } else if (screenWidth < 1200) {
      return _buildTabletTimeline();
    } else {
      return _buildDesktopTimeline();
    }
  }
  
  Widget _buildMobileTimeline() {
    // 移动端:单列布局,时间线在左侧
    return ListView.builder(
      padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16),
      itemCount: widget.events.length,
      itemBuilder: (context, index) {
        return _buildTimelineItem(widget.events[index], index, false);
      },
    );
  }
  
  Widget _buildTabletTimeline() {
    // 平板端:单列布局,但卡片更宽
    return ListView.builder(
      padding: EdgeInsets.symmetric(vertical: 16, horizontal: 32),
      itemCount: widget.events.length,
      itemBuilder: (context, index) {
        return _buildTimelineItem(widget.events[index], index, false);
      },
    );
  }
  
  Widget _buildDesktopTimeline() {
    // PC 端:左右交替布局,充分利用屏幕空间
    return ListView.builder(
      padding: EdgeInsets.symmetric(vertical: 16, horizontal: 48),
      itemCount: widget.events.length,
      itemBuilder: (context, index) {
        final isLeftSide = index % 2 == 0;
        return _buildAlternatingTimelineItem(
          widget.events[index],
          index,
          isLeftSide,
        );
      },
    );
  }
  
  Widget _buildTimelineItem(TimelineEvent event, int index, bool isAlternating) {
    // 基础时间线项实现
    return Padding(
      padding: EdgeInsets.symmetric(vertical: 12),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          _buildTimelineMarker(event),
          SizedBox(width: 16),
          Expanded(child: _buildEventCard(event)),
        ],
      ),
    );
  }
  
  Widget _buildAlternatingTimelineItem(
    TimelineEvent event,
    int index,
    bool isLeftSide,
  ) {
    // 左右交替的时间线项
    final cardWidget = Expanded(child: _buildEventCard(event));
    final markerWidget = _buildTimelineMarker(event);
    
    if (isLeftSide) {
      return Padding(
        padding: EdgeInsets.symmetric(vertical: 12),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            cardWidget,
            SizedBox(width: 16),
            markerWidget,
            SizedBox(width: 16),
            Expanded(child: SizedBox()),
          ],
        ),
      );
    } else {
      return Padding(
        padding: EdgeInsets.symmetric(vertical: 12),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Expanded(child: SizedBox()),
            SizedBox(width: 16),
            markerWidget,
            SizedBox(width: 16),
            cardWidget,
          ],
        ),
      );
    }
  }
  
  Widget _buildTimelineMarker(TimelineEvent event) {
    return Container(
      width: 40,
      height: 40,
      decoration: BoxDecoration(
        color: _getMarkerColor(event.status),
        shape: BoxShape.circle,
        border: Border.all(color: Colors.white, width: 3),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.1),
            blurRadius: 4,
          ),
        ],
      ),
      child: Center(
        child: Icon(
          _getEventIcon(event.category),
          color: Colors.white,
          size: 20,
        ),
      ),
    );
  }
  
  Widget _buildEventCard(TimelineEvent event) {
    return Card(
      elevation: 2,
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  _formatDateTime(event.dateTime),
                  style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
                ),
                _buildStatusBadge(event.status),
              ],
            ),
            SizedBox(height: 8),
            Text(
              event.title,
              style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 8),
            Text(
              event.description,
              style: TextStyle(fontSize: 14, color: Colors.grey.shade700),
              maxLines: 3,
              overflow: TextOverflow.ellipsis,
            ),
          ],
        ),
      ),
    );
  }
  
  Color _getMarkerColor(String status) {
    switch (status) {
      case 'completed':
        return Colors.green;
      case 'in_progress':
        return Colors.blue;
      case 'pending':
        return Colors.grey;
      default:
        return Colors.blue;
    }
  }
  
  IconData _getEventIcon(String category) {
    switch (category) {
      case 'milestone':
        return Icons.flag;
      case 'release':
        return Icons.rocket;
      case 'bug_fix':
        return Icons.bug_report;
      case 'feature':
        return Icons.lightbulb;
      default:
        return Icons.circle;
    }
  }
  
  Widget _buildStatusBadge(String status) {
    final statusMap = {
      'completed': ('已完成', Colors.green),
      'in_progress': ('进行中', Colors.blue),
      'pending': ('待做', Colors.grey),
    };
    
    final (label, color) = statusMap[status] ?? ('未知', Colors.grey);
    
    return Container(
      padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
      decoration: BoxDecoration(
        color: color.withOpacity(0.1),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: color, width: 0.5),
      ),
      child: Text(
        label,
        style: TextStyle(fontSize: 12, color: color, fontWeight: FontWeight.w600),
      ),
    );
  }
  
  String _formatDateTime(DateTime dateTime) {
    return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}';
  }
}

这个实现展示了如何根据屏幕宽度动态调整时间线的布局,在 PC 端采用左右交替布局以充分利用屏幕空间。

高级话题:时间线的企业级应用

1. 动态/响应式设计与多屏幕适配

在企业应用中,时间线需要在不同设备上提供一致的体验。这包括根据屏幕宽度动态调整布局、字体大小、间距等。

class ResponsiveTimelineManager {
  static const double MOBILE_WIDTH = 600;
  static const double TABLET_WIDTH = 1200;
  
  static DeviceType getDeviceType(BuildContext context) {
    final width = MediaQuery.of(context).size.width;
    if (width < MOBILE_WIDTH) return DeviceType.mobile;
    if (width < TABLET_WIDTH) return DeviceType.tablet;
    return DeviceType.desktop;
  }
  
  static EdgeInsets getPadding(BuildContext context) {
    final deviceType = getDeviceType(context);
    switch (deviceType) {
      case DeviceType.mobile:
        return EdgeInsets.symmetric(horizontal: 16, vertical: 12);
      case DeviceType.tablet:
        return EdgeInsets.symmetric(horizontal: 32, vertical: 16);
      case DeviceType.desktop:
        return EdgeInsets.symmetric(horizontal: 48, vertical: 20);
    }
  }
  
  static double getMarkerSize(BuildContext context) {
    final deviceType = getDeviceType(context);
    switch (deviceType) {
      case DeviceType.mobile:
        return 32;
      case DeviceType.tablet:
        return 36;
      case DeviceType.desktop:
        return 40;
    }
  }
  
  static double getLineHeight(BuildContext context) {
    final deviceType = getDeviceType(context);
    switch (deviceType) {
      case DeviceType.mobile:
        return 40;
      case DeviceType.tablet:
        return 50;
      case DeviceType.desktop:
        return 60;
    }
  }
}

enum DeviceType { mobile, tablet, desktop }

2. 动画与过渡效果

为时间线添加动画可以提升用户体验。常见的动画包括事件卡片的淡入、缩放、滑动等。

class AnimatedTimelineWidget extends StatefulWidget {
  final List<TimelineEvent> events;
  
  const AnimatedTimelineWidget({required this.events});
  
  
  State<AnimatedTimelineWidget> createState() => _AnimatedTimelineWidgetState();
}

class _AnimatedTimelineWidgetState extends State<AnimatedTimelineWidget>
    with TickerProviderStateMixin {
  late List<AnimationController> _controllers;
  
  
  void initState() {
    super.initState();
    _controllers = List.generate(
      widget.events.length,
      (index) => AnimationController(
        duration: Duration(milliseconds: 500 + (index * 100)),
        vsync: this,
      ),
    );
    
    // 依次播放动画
    for (var i = 0; i < _controllers.length; i++) {
      Future.delayed(Duration(milliseconds: i * 100), () {
        if (mounted) _controllers[i].forward();
      });
    }
  }
  
  
  void dispose() {
    for (var controller in _controllers) {
      controller.dispose();
    }
    super.dispose();
  }
  
  
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: widget.events.length,
      itemBuilder: (context, index) {
        return ScaleTransition(
          scale: Tween<double>(begin: 0.8, end: 1.0).animate(
            CurvedAnimation(parent: _controllers[index], curve: Curves.easeOut),
          ),
          child: FadeTransition(
            opacity: Tween<double>(begin: 0, end: 1).animate(
              CurvedAnimation(parent: _controllers[index], curve: Curves.easeIn),
            ),
            child: _buildTimelineItem(widget.events[index], index),
          ),
        );
      },
    );
  }
  
  Widget _buildTimelineItem(TimelineEvent event, int index) {
    // 时间线项的构建逻辑
    return Card(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(event.title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
            SizedBox(height: 8),
            Text(event.description),
          ],
        ),
      ),
    );
  }
}

3. 搜索/过滤/排序功能

企业应用中通常需要对时间线进行搜索、过滤和排序,以便用户快速找到所需信息。

class TimelineFilterManager {
  List<TimelineEvent> _allEvents;
  List<TimelineEvent> _filteredEvents;
  
  String _searchQuery = '';
  String _selectedStatus = '';
  String _selectedCategory = '';
  String _sortBy = 'date'; // date, priority, title
  bool _sortAscending = false;
  
  TimelineFilterManager(this._allEvents) : _filteredEvents = _allEvents;
  
  void setSearchQuery(String query) {
    _searchQuery = query;
    _applyFilters();
  }
  
  void setStatusFilter(String status) {
    _selectedStatus = status;
    _applyFilters();
  }
  
  void setCategoryFilter(String category) {
    _selectedCategory = category;
    _applyFilters();
  }
  
  void setSortBy(String sortBy, bool ascending) {
    _sortBy = sortBy;
    _sortAscending = ascending;
    _applyFilters();
  }
  
  void _applyFilters() {
    _filteredEvents = _allEvents.where((event) {
      // 搜索过滤
      final matchesSearch = _searchQuery.isEmpty ||
          event.title.toLowerCase().contains(_searchQuery.toLowerCase()) ||
          event.description.toLowerCase().contains(_searchQuery.toLowerCase());
      
      // 状态过滤
      final matchesStatus = _selectedStatus.isEmpty || event.status == _selectedStatus;
      
      // 分类过滤
      final matchesCategory = _selectedCategory.isEmpty || event.category == _selectedCategory;
      
      return matchesSearch && matchesStatus && matchesCategory;
    }).toList();
    
    // 排序
    _filteredEvents.sort((a, b) {
      int comparison = 0;
      switch (_sortBy) {
        case 'date':
          comparison = a.dateTime.compareTo(b.dateTime);
          break;
        case 'priority':
          comparison = a.priority.compareTo(b.priority);
          break;
        case 'title':
          comparison = a.title.compareTo(b.title);
          break;
      }
      return _sortAscending ? comparison : -comparison;
    });
  }
  
  List<TimelineEvent> get filteredEvents => _filteredEvents;
  
  List<String> get availableStatuses => _allEvents.map((e) => e.status).toSet().toList();
  List<String> get availableCategories => _allEvents.map((e) => e.category).toSet().toList();
}

4. 选择与批量操作

支持多选事件并进行批量操作(如删除、导出、更改状态等)。

class SelectableTimelineWidget extends StatefulWidget {
  final List<TimelineEvent> events;
  final Function(List<TimelineEvent>) onBatchAction;
  
  const SelectableTimelineWidget({
    required this.events,
    required this.onBatchAction,
  });
  
  
  State<SelectableTimelineWidget> createState() => _SelectableTimelineWidgetState();
}

class _SelectableTimelineWidgetState extends State<SelectableTimelineWidget> {
  final Set<String> _selectedEventIds = {};
  
  void _toggleSelection(String eventId) {
    setState(() {
      if (_selectedEventIds.contains(eventId)) {
        _selectedEventIds.remove(eventId);
      } else {
        _selectedEventIds.add(eventId);
      }
    });
  }
  
  void _selectAll() {
    setState(() {
      _selectedEventIds.addAll(widget.events.map((e) => e.id));
    });
  }
  
  void _clearSelection() {
    setState(() {
      _selectedEventIds.clear();
    });
  }
  
  
  Widget build(BuildContext context) {
    return Column(
      children: [
        if (_selectedEventIds.isNotEmpty)
          Container(
            padding: EdgeInsets.all(16),
            color: Colors.blue.shade50,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text('已选择 ${_selectedEventIds.length} 项'),
                Row(
                  children: [
                    TextButton(
                      onPressed: _clearSelection,
                      child: Text('取消选择'),
                    ),
                    SizedBox(width: 8),
                    ElevatedButton(
                      onPressed: () {
                        final selectedEvents = widget.events
                            .where((e) => _selectedEventIds.contains(e.id))
                            .toList();
                        widget.onBatchAction(selectedEvents);
                      },
                      child: Text('批量操作'),
                    ),
                  ],
                ),
              ],
            ),
          ),
        Expanded(
          child: ListView.builder(
            itemCount: widget.events.length,
            itemBuilder: (context, index) {
              final event = widget.events[index];
              final isSelected = _selectedEventIds.contains(event.id);
              
              return CheckboxListTile(
                value: isSelected,
                onChanged: (_) => _toggleSelection(event.id),
                title: Text(event.title),
                subtitle: Text(event.description),
              );
            },
          ),
        ),
      ],
    );
  }
}

5. 加载与缓存策略

对于大数据集,需要实现分页加载和缓存策略以提高性能。

class TimelineDataManager {
  final int pageSize = 20;
  int _currentPage = 0;
  List<TimelineEvent> _cachedEvents = [];
  bool _hasMore = true;
  
  Future<List<TimelineEvent>> loadMoreEvents() async {
    if (!_hasMore) return [];
    
    // 模拟网络请求
    await Future.delayed(Duration(milliseconds: 500));
    
    final newEvents = _generateMockEvents(_currentPage * pageSize, pageSize);
    _cachedEvents.addAll(newEvents);
    _currentPage++;
    
    if (newEvents.length < pageSize) {
      _hasMore = false;
    }
    
    return newEvents;
  }
  
  List<TimelineEvent> _generateMockEvents(int start, int count) {
    return List.generate(count, (index) {
      return TimelineEvent(
        id: 'event_${start + index}',
        dateTime: DateTime.now().subtract(Duration(days: start + index)),
        title: '事件 ${start + index}',
        description: '这是第 ${start + index} 个事件的描述',
        status: ['completed', 'in_progress', 'pending'][index % 3],
        priority: (index % 5) + 1,
        category: ['milestone', 'release', 'bug_fix', 'feature'][index % 4],
      );
    });
  }
  
  List<TimelineEvent> get cachedEvents => _cachedEvents;
  bool get hasMore => _hasMore;
}

6. 键盘导航与快捷键

为 PC 端应用提供键盘导航支持,提高用户效率。

class KeyboardNavigableTimeline extends StatefulWidget {
  final List<TimelineEvent> events;
  
  const KeyboardNavigableTimeline({required this.events});
  
  
  State<KeyboardNavigableTimeline> createState() => _KeyboardNavigableTimelineState();
}

class _KeyboardNavigableTimelineState extends State<KeyboardNavigableTimeline> {
  int _focusedIndex = 0;
  late FocusNode _focusNode;
  
  
  void initState() {
    super.initState();
    _focusNode = FocusNode();
  }
  
  
  void dispose() {
    _focusNode.dispose();
    super.dispose();
  }
  
  void _handleKeyEvent(RawKeyEvent event) {
    if (event.isKeyPressed(LogicalKeyboardKey.arrowDown)) {
      setState(() {
        _focusedIndex = (_focusedIndex + 1) % widget.events.length;
      });
    } else if (event.isKeyPressed(LogicalKeyboardKey.arrowUp)) {
      setState(() {
        _focusedIndex = (_focusedIndex - 1 + widget.events.length) % widget.events.length;
      });
    } else if (event.isKeyPressed(LogicalKeyboardKey.enter)) {
      _handleEventSelection(widget.events[_focusedIndex]);
    }
  }
  
  void _handleEventSelection(TimelineEvent event) {
    // 处理事件选择
    print('选择事件: ${event.title}');
  }
  
  
  Widget build(BuildContext context) {
    return RawKeyboardListener(
      focusNode: _focusNode,
      onKey: _handleKeyEvent,
      child: ListView.builder(
        itemCount: widget.events.length,
        itemBuilder: (context, index) {
          final event = widget.events[index];
          final isFocused = index == _focusedIndex;
          
          return Container(
            decoration: BoxDecoration(
              border: isFocused
                  ? Border.all(color: Colors.blue, width: 2)
                  : null,
            ),
            child: Card(
              child: Padding(
                padding: EdgeInsets.all(16),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(event.title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
                    SizedBox(height: 8),
                    Text(event.description),
                  ],
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}

7. 无障碍支持与屏幕阅读器

确保时间线对屏幕阅读器和无障碍工具友好。

class AccessibleTimelineWidget extends StatelessWidget {
  final List<TimelineEvent> events;
  
  const AccessibleTimelineWidget({required this.events});
  
  
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: events.length,
      itemBuilder: (context, index) {
        final event = events[index];
        
        return Semantics(
          label: '事件 ${index + 1}${events.length}',
          button: true,
          enabled: true,
          onTap: () => print('选择事件: ${event.title}'),
          child: Card(
            child: Padding(
              padding: EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Semantics(
                    label: '事件标题',
                    child: Text(
                      event.title,
                      style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                    ),
                  ),
                  SizedBox(height: 8),
                  Semantics(
                    label: '事件描述',
                    child: Text(event.description),
                  ),
                  SizedBox(height: 8),
                  Semantics(
                    label: '事件状态: ${_getStatusLabel(event.status)}',
                    child: Text(
                      _getStatusLabel(event.status),
                      style: TextStyle(color: Colors.grey),
                    ),
                  ),
                ],
              ),
            ),
          ),
        );
      },
    );
  }
  
  String _getStatusLabel(String status) {
    switch (status) {
      case 'completed':
        return '已完成';
      case 'in_progress':
        return '进行中';
      case 'pending':
        return '待做';
      default:
        return '未知';
    }
  }
}

8. 样式自定义与主题适配

支持自定义样式和主题切换。

class TimelineTheme {
  final Color markerColor;
  final Color lineColor;
  final Color cardBackgroundColor;
  final Color textColor;
  final Color accentColor;
  final double markerSize;
  final double lineWidth;
  final double cardElevation;
  
  const TimelineTheme({
    this.markerColor = Colors.blue,
    this.lineColor = Colors.blue,
    this.cardBackgroundColor = Colors.white,
    this.textColor = Colors.black87,
    this.accentColor = Colors.blue,
    this.markerSize = 40,
    this.lineWidth = 2,
    this.cardElevation = 2,
  });
  
  static TimelineTheme light() {
    return TimelineTheme(
      markerColor: Colors.blue,
      lineColor: Colors.blue.shade200,
      cardBackgroundColor: Colors.white,
      textColor: Colors.black87,
      accentColor: Colors.blue,
    );
  }
  
  static TimelineTheme dark() {
    return TimelineTheme(
      markerColor: Colors.blue.shade300,
      lineColor: Colors.blue.shade700,
      cardBackgroundColor: Colors.grey.shade900,
      textColor: Colors.white,
      accentColor: Colors.blue.shade300,
    );
  }
}

class ThemedTimelineWidget extends StatelessWidget {
  final List<TimelineEvent> events;
  final TimelineTheme theme;
  
  const ThemedTimelineWidget({
    required this.events,
    required this.theme,
  });
  
  
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: events.length,
      itemBuilder: (context, index) {
        final event = events[index];
        
        return Card(
          elevation: theme.cardElevation,
          color: theme.cardBackgroundColor,
          child: Padding(
            padding: EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  event.title,
                  style: TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.bold,
                    color: theme.textColor,
                  ),
                ),
                SizedBox(height: 8),
                Text(
                  event.description,
                  style: TextStyle(color: theme.textColor),
                ),
              ],
            ),
          ),
        );
      },
    );
  }
}

9. 数据持久化与导出

支持将时间线数据导出为不同格式(CSV、JSON、PDF 等)。

class TimelineExporter {
  static String exportToCSV(List<TimelineEvent> events) {
    final buffer = StringBuffer();
    buffer.writeln('ID,日期,标题,描述,状态,优先级,分类');
    
    for (var event in events) {
      buffer.writeln(
        '${event.id},'
        '${event.dateTime},'
        '"${event.title}",'
        '"${event.description}",'
        '${event.status},'
        '${event.priority},'
        '${event.category}',
      );
    }
    
    return buffer.toString();
  }
  
  static String exportToJSON(List<TimelineEvent> events) {
    final jsonList = events.map((e) => {
      'id': e.id,
      'dateTime': e.dateTime.toIso8601String(),
      'title': e.title,
      'description': e.description,
      'status': e.status,
      'priority': e.priority,
      'category': e.category,
    }).toList();
    
    return jsonEncode(jsonList);
  }
  
  static Future<void> saveToFile(String filename, String content) async {
    final directory = await getApplicationDocumentsDirectory();
    final file = File('${directory.path}/$filename');
    await file.writeAsString(content);
  }
}

10. 单元测试与集成测试

为时间线功能编写全面的测试。

void main() {
  group('Timeline Tests', () {
    test('时间线事件排序', () {
      final events = [
        TimelineEvent(
          id: '1',
          dateTime: DateTime(2024, 1, 3),
          title: '事件3',
          description: '描述3',
          status: 'completed',
          priority: 1,
          category: 'milestone',
        ),
        TimelineEvent(
          id: '2',
          dateTime: DateTime(2024, 1, 1),
          title: '事件1',
          description: '描述1',
          status: 'completed',
          priority: 1,
          category: 'milestone',
        ),
      ];
      
      events.sort((a, b) => a.dateTime.compareTo(b.dateTime));
      
      expect(events[0].title, '事件1');
      expect(events[1].title, '事件3');
    });
    
    test('时间线过滤功能', () {
      final events = [
        TimelineEvent(
          id: '1',
          dateTime: DateTime.now(),
          title: '事件1',
          description: '描述1',
          status: 'completed',
          priority: 1,
          category: 'milestone',
        ),
        TimelineEvent(
          id: '2',
          dateTime: DateTime.now(),
          title: '事件2',
          description: '描述2',
          status: 'pending',
          priority: 2,
          category: 'feature',
        ),
      ];
      
      final filtered = events.where((e) => e.status == 'completed').toList();
      
      expect(filtered.length, 1);
      expect(filtered[0].title, '事件1');
    });
  });
  
  testWidgets('Timeline Widget 集成测试', (WidgetTester tester) async {
    final events = [
      TimelineEvent(
        id: '1',
        dateTime: DateTime.now(),
        title: '测试事件',
        description: '测试描述',
        status: 'completed',
        priority: 1,
        category: 'milestone',
      ),
    ];
    
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: TimelineWidget(events: events),
        ),
      ),
    );
    
    expect(find.text('测试事件'), findsOneWidget);
    expect(find.text('测试描述'), findsOneWidget);
  });
}

OpenHarmony PC 端适配要点

  1. 屏幕宽度检测:使用 MediaQuery.of(context).size.width 检测屏幕宽度,根据不同范围应用不同布局
  2. 响应式布局:在 PC 端采用左右交替布局,充分利用屏幕空间
  3. 最大宽度限制:PC 端通常限制内容最大宽度在 900-1200px
  4. 鼠标交互:使用 MouseRegion 提供悬停效果和光标反馈
  5. 键盘导航:支持方向键、Enter、Delete 等快捷键操作
  6. 无障碍支持:使用 Semantics 提供屏幕阅读器支持

实际应用场景

  1. 项目管理:展示项目的各个阶段和里程碑
  2. 版本管理:显示软件的版本发布历史
  3. 工作流:展示业务流程的各个步骤
  4. 用户活动:记录用户的操作历史
  5. 系统日志:展示系统事件和警告

扩展建议

  1. 添加事件详情弹窗或侧边栏
  2. 支持事件编辑和删除功能
  3. 实现事件搜索和高级过滤
  4. 添加事件统计和分析
  5. 支持事件导出和打印
  6. 实现实时更新和通知

总结

时间线是展示事件序列的强大工具。通过合理的设计和实现,可以创建出功能完整、用户体验良好的时间线系统。在 PC 端应用中,充分利用屏幕空间、提供键盘导航和无障碍支持是关键。通过本案例的学习,你可以掌握时间线的设计原则和实现技巧,并将其应用到实际项目中。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐