1

MVVM架构的Swift版本的知乎日报

前言

在自学了Objective-C和Swift基本的语法知识后,也看了不少有益的博客文章和示例代码。在这个阶段中我主要的学习过程就是跟着文章中内容一步步来练习,但是这些文章大部分都是关于某一个特定的知识点或者内容。为了检查自己的学习情况以及提高对于各部分内容的理解,我决定参照别人的代码自己动手写个完整功能的App。

说明:因为是新手不可避免的会参照很多的文章和代码。本文主要参照了MVVM架构OC版本的知乎日报地址和知乎日报API接口的分析地址[2],当然还使用了第三方类库Alamofire、AlamofireImage我在这里对这些作者表示感谢与敬意。另外示例使用了部分Swift2.2的特性,所以需要在Xcode7.3以上才能运行。

简介

整个工程就是模仿官方版本来进行实现的,除了与用户相关的部分功能外,其余主体功能已经完整实现了。整个工程最终的效果图如下:

clipboard.png

clipboard.png

工程采用的架构是MVVM,相比于常用的MVC前者简化了ViewController的操作便于后期的维护。前者主要将MVC中的一些数据和网络请求全部都抽离到了ViewModel类中,ViewController只需要调用ViewModel中函数或者数据就行了。具体的细节我就不废话了,详细内容可以自行谷歌、必应或者查看ObjcMVVM介绍更轻量的 View Controllers

直接上码

工程的主体可以大体上分为四个部分:主页、左侧滑栏、文章显示、主题显示,其中最主要的是主页和侧滑栏。下面我们来简单介绍一些部分代码。

主页

从上面的效果展示图中,你可以发现主页其实可以大体分为上下两个部分。上半部分主体是一个滚动展示头条内容和一个显示侧滑栏的按键以及一个下拉刷新的动画视图;下半部分主要就是一个展示数据的TableView。主页使用到的Model定义如下:

class StoryModel: NSObject {
    var title:String = ""
    var storyID: Int = 0
    var images = [String]()
    var isMultipic: Bool = false
    var type:Int = 0
    var image: String = ""
    
    //一些方法
    ....
}

上半部分的滚动展示其实可以理解为一个水平的滚动视图里面包含了一个相同结构的视图(每一个都是图片加标题)和UIPageControl,然后定时进行滚动切换。滚动视图的实现如下:

protocol CarouseViewDelegate: NSObjectProtocol {
    func didSelectItemWithTag(tag: Int)
}

class CarouseView: UIView,UIScrollViewDelegate {
    var topStories:[StoryModel] = []
    weak var delegate: CarouseViewDelegate?
    
    ...
    
    override init(frame: CGRect) {
        ...
    
        for index in 0...6 {
            
            let tsv = TopStoryView.init(frame: CGRectMake(BNCScreenWidth * i ,0,BNCScreenWidth ,300) )
            tsv.addGestureRecognizer(UITapGestureRecognizer.init(target: self, action: #selector(CarouseView.tap(_:))))
            tsv.tag = index + 100
            scrollView.addSubview(tsv)
            i += 1
        }
        
        ...            
    }
    
    func tap(recognizer:UIGestureRecognizer ) {
        self.delegate?.didSelectItemWithTag((recognizer.view?.tag)!)
    }
    
    
    func updateUIWithTopStories(stories:[StoryModel]) {
        
        for index in 0...topStories.count - 1 {
            let tsv = scrollView.viewWithTag(100 + index) as! TopStoryView
            let model = topStories[index]
            tsv.imageView.af_setImageWithURL(NSURL.init(string: model.image)!)
            let attStr = NSAttributedString.init(string: model.title, attributes: [NSFontAttributeName:UIFont.boldSystemFontOfSize(21),NSForegroundColorAttributeName:UIColor.whiteColor()])
            let size = attStr.boundingRectWithSize(CGSizeMake(BNCScreenWidth - 30, 200), options: NSStringDrawingOptions.UsesLineFragmentOrigin.union(NSStringDrawingOptions.UsesFontLeading), context: nil).size
            tsv.label.frame = CGRectMake(15, 0, BNCScreenWidth, size.height)
            tsv.label.setBottom(240)
            tsv.label.attributedText = attStr
        }
        timer = NSTimer.scheduledTimerWithTimeInterval(5, target: self, selector: #selector(CarouseView.nextStoryDisplay), userInfo: nil, repeats: true)
    }
    
    ...
           
}

其中初始化时scrollView中共添加了7个TopStoryView,这是因为从获取的数据中我们得知TopStory只有5个,所有我们分别将第一个和最后一个TopStory多添加一次来实现循环往复。然后定时器设置成枚5秒自动切换下一个视图。

下半部分就是一个TableView,但是TableView中的数据并不是在ViewController中获取的,所有的数据都是在ViewModel中实现获取和更新的。下面我们看一下ViewModel中的代码:

class HomeViewModel: NSObject {
    //所以已加载的Tableview数据
    var daysDataList: [SectionViewModel]! = []
    //滚动展示视图的数据
    var top_stories: [StoryModel]! = []
    //标记是否正在加载数据
    var isLoading: Bool = false
    //所有已加载的文章id数组
    var storiesID: [Int]! = []
    //当前日期
    var currentLoadDayStr: String! = ""
    
    ...
    
       //获取最新的新闻
    func getLatestStories() {
        
        Alamofire.request(.GET, "http://news-at.zhihu.com/api/4/news/latest")
            .responseJSON { response in
                if let JSON = response.result.value {
                    print("JSON: \(JSON)")
                    self.isLoading = false
                    self.initWithLatestStories(withDictionary: JSON as! [String : AnyObject])
                }
        }
    }
    
    ... 
}

其中SectionViewModel类对应是tableview中某一Section的ViewModel。然后我们在主页的视图控制器里面直接使用HomeViewModel对应的函数或者变量值。当用户选择了某一行之后我们再将此cell对应的Model传递给文章视图控制器的ViewModel:

// MARK - UITableViewDelegate
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
    let story = viewmodel.storyAtIndexPath(indexPath)
    let vm = StoryContentViewModel.init(withCurID: story.storyID, storiesID: viewmodel.storiesID)
    vm.getStoryContentWithStoryID(story.storyID)
    let storyContentVC = StoryContentViewController.init(WithViewModel: vm)
    let appdele = UIApplication.sharedApplication().delegate as! AppDelegate
    appdele.mainVC.navigationController?.pushViewController(storyContentVC, animated: true)
}

具体的内容展示部分的代码就不多介绍了,后面可以自己看代码。接下来我们将注意力放到左侧滑栏上面。

clipboard.png

侧滑栏

侧滑栏部分主要有一个UIScrollView来展示对应的主题列表。而主题列表的获取有分为用户已经订阅的和其它主题。LeftMenuViewController中该部分代码如下:

func getThemeList() {
    Alamofire.request(.GET, "http://news-at.zhihu.com/api/4/themes")
        .responseJSON { response in
            if let JSON = response.result.value {
                let  subArr = JSON["subscribed"] as! [[String:AnyObject]]
                for dic in subArr {
                    let model = ThemeItemModel.init(withDictionary: dic)
                    self.subscribedList.append(model)
                }
                
                let othArr = JSON["others"] as! [[String:AnyObject]]
                for dic in othArr {
                    let model = ThemeItemModel.init(withDictionary: dic)
                    self.othersList.append(model)
                }
                self.setMentItems()
            }
    }
}

func setMentItems() {
    mainScrollView.contentSize = CGSizeMake(0, CGFloat(1 + subscribedList.count + othersList.count ) * 44)
    let homeItem = NSBundle.mainBundle().loadNibNamed("TMItemView", owner: self, options: nil).first as! TMItemView
    
    homeItem.frame = CGRectMake(0, 0, mainScrollView.width(), 44)
    homeItem.menuTitleLb.text = "首页";
    homeItem.menuImaView.image = UIImage.init(named: "Menu_Icon_Home")
    homeItem.addGestureRecognizer(UITapGestureRecognizer.init(target: self, action: #selector(LeftMenuViewController.showHome(_:))))
    mainScrollView.addSubview(homeItem)
    
    var tempHeight = homeItem.height()
    themeItems.appendContentsOf(subscribedList)
    themeItems.appendContentsOf(othersList)
    for index in 0...themeItems.count - 1 {
        let itemView = NSBundle.mainBundle().loadNibNamed("TMItemView", owner: self, options: nil).first as! TMItemView
        itemView.frame = CGRectMake(0 , tempHeight, mainScrollView.width(),44)
        itemView.menuImaView.removeFromSuperview()
        itemView.addConstraints([NSLayoutConstraint.init(item: itemView.menuTitleLb, attribute: .Leading, relatedBy: .Equal, toItem: itemView, attribute: .Leading, multiplier: 1, constant: 4)])
        
        let model = themeItems[index]
        itemView.menuTitleLb.text = model.name
        itemView.addGestureRecognizer(UITapGestureRecognizer.init(target: self, action: #selector(LeftMenuViewController.didSelectedMenuItem(_:))))
        itemView.tag = index
        mainScrollView.addSubview(itemView)
        tempHeight += 44
    }
    
}

当用户点击某一个主题的时候,我们需要将该主题所对应的id传递到主题展示控制器当中。该部分代码如下:

func didSelectedMenuItem(recognizer :UIGestureRecognizer) {
    let model = themeItems[(recognizer.view?.tag)!] 
    let dailyThemeVC = DailyThemesViewController.init()
    dailyThemeVC.themeID = model.themeID
    let subNavigationVC = UINavigationController.init(rootViewController: dailyThemeVC)
    let appdele = UIApplication.sharedApplication().delegate as! AppDelegate
    appdele.mainVC.presentViewController(subNavigationVC, animated: true, completion: nil)

}

效果图:

clipboard.png

最后的主视图

为了实现侧滑栏的显示和隐藏,我们将左滑视图和主页视图同时添加到同一个主视图当中。在该主视图中实现两者之间的切换与显示:

func showMainView(){
    UIView.animateWithDuration(0.2, delay: 0, options: .CurveEaseInOut, animations: {
        self.mainView.transform = CGAffineTransformIdentity
        self.leftMenuView.transform = CGAffineTransformConcat(CGAffineTransformScale(CGAffineTransformIdentity,1,1),CGAffineTransformTranslate(CGAffineTransformIdentity,0,0))
        
        }, completion: { (finished)  in
           self.distance = 0
            self.mainView.removeGestureRecognizer(self.tap)
    })

}

func showLeftMenuView() {
    UIView.animateWithDuration(0.2, delay: 0, options: .CurveEaseInOut, animations: {
        self.leftMenuView.transform = CGAffineTransformIdentity
        self.mainView.transform = CGAffineTransformConcat(CGAffineTransformScale(CGAffineTransformIdentity,1,1),CGAffineTransformTranslate(CGAffineTransformIdentity,self.BNCScreenWidth * 0.6,0))
        
        }, completion: { (finished)  in
            self.distance = self.BNCScreenWidth * 0.6
            self.leftMenuView.removeGestureRecognizer(self.tap)
    })
}

func pan(recongizer:UIPanGestureRecognizer) {
    let moveX = recongizer.translationInView(self.view).x
    let truedistance = distance + moveX
    let percent = truedistance / (BNCScreenWidth * 0.6)
    
    if truedistance >= 0 && truedistance <= BNCScreenWidth * 0.6 {
        mainView.transform = CGAffineTransformConcat(CGAffineTransformScale(CGAffineTransformIdentity,1,1 ),CGAffineTransformTranslate(CGAffineTransformIdentity,truedistance,0))
        
        leftMenuView.transform = CGAffineTransformConcat(CGAffineTransformScale(CGAffineTransformIdentity,1,1),
        CGAffineTransformTranslate(CGAffineTransformIdentity,   BNCScreenWidth * 0.6 * (percent - 1),0))
        
    }
    
    if recongizer.state == .Ended {
        if truedistance < BNCScreenWidth * 0.3 {
            showMainView()
        } else {
            showLeftMenuView()
        }
    }

}

总结

虽然是参照他人代码进行Swift改写的,但是整个过程还是很有收获的。作为新手代码中肯定会存在很多的不足之处,但是我还是很开心。在改写的过程中加深了我对一些概念和特性的理解,并且锻炼了自己的能力。最后我还是要感谢开源自己代码的作者。

本项目的完整代码地址GitHubCoding


BigNerdCoding
1.2k 声望125 粉丝

个人寄语: