
前言
断断续续的已经学习Swift
一年多了, 从1.2
到现在的2.1
, 一直在语法之间徘徊, 学一段时间, 工作一忙, 再捡起来隔段时间又忘了.思来想去, 趁着这两个月加班不是特别多, 就决定用swift
仿写一个完整项目.
花田小憩:是一个植物美学生活平台, 以自然生活为主导, 提倡植物学生活方法, 倡导美学标准的生活态度的一个APP.
个人文字功底有限, 就我而言, 这款APP做的挺唯美
的...
声明
此花田小憩
项目里面的都是真实接口, 真实数据, 仅供学习, 毋作其他用途!!!
TIP: 如果截图显示不全的话, 可以访问下面的链接
项目部分截图
由于项目的大体功能都已经实现了的, 所以整个项目还是比较庞大的.所以, 下面罗列部分功能的截图.
由于gif录制的时候, 会重新渲染一遍图片, 所以导致项目中用到高斯模糊
的地方, 看起来感觉比较乱, 实际效果还是不错的.
项目环境
编译器 : Xcode7.2
语言 : Swift2.1
整个项目都是采用纯代码
开发模式
第三方框架
use_frameworks! platform :ios, "8.0" target 'Floral' do pod 'SnapKit', '~> 0.20.0' ## 自动布局 pod 'Alamofire', '~> 3.3.1' ## 网络请求, swift版的AFN pod 'Kingfisher', '~> 2.3.1' ## 轻量级的SDWebImage end
还用到了MBProgressHUD
.
除此之外,几乎全部都是自己造的小轮子
...
目录结构详解
Classes
下包含7个功能目录:
①Resources
: 项目用到的资源,包含plist文件
, js文件
和字体
②Network
: 网络请求, 所有的网络请求都在这里面, 接口
和参数
都有详细的注释
③Tool
: 包含tools(工具类)
, 3rdLib(第三方:友盟分享, MBProgressHUD )
, Category(所有项目用到的分类)
④Home
: 首页(专题), 包含专题分类
, 详情
, 每周Top10
, 评论
, 分享
等等功能模块
⑤Main
: UITabBarController
, UINavigationController
设置以及新特性
⑥Malls
: 商城, 包含商城分类
, 商品搜索
, 详情
, 购物车
, 购买
, 订单
, 地址管理
, 支付
等等功能模块
⑦Profile
: 个人中心, 专栏作者, 登录/注册/忘记密码, 设置等功能模块
大家可以下载项目, 对照这个目录结构进行查看, 很典型的MVC
文件结构, 还是很方便的.
项目部分功能模块详解
① 新特性NewFeatureViewController
: 这个功能模块还是比较简单的, 用到了UICollectionViewController
, 然后自己添加了UIPageControl
, 只需要监听最后一个cell的点击即可.
这儿有一个注意点是: 我们需要根据版本号来判断是进入新特性界面, 广告页还是首页.
private let SLBundleShortVersionString = "SLBundleShortVersionString" // MARK: - 判断版本号 private func toNewFeature() -> Bool { // 根据版本号来确定是否进入新特性界面 let currentVersion = NSBundle.mainBundle().infoDictionary!["CFBundleShortVersionString"] as! String let oldVersion = NSUserDefaults.standardUserDefaults().objectForKey(SLBundleShortVersionString) ?? "" // 如果当前的版本号和本地保存的版本比较是降序, 则需要显示新特性 if (currentVersion.compare(oldVersion as! String)) == .OrderedDescending{ // 保存当前的版本 NSUserDefaults.standardUserDefaults().setObject(currentVersion, forKey: SLBundleShortVersionString) return true } return false }
② 下拉刷新RefreshControl
: 在这个项目中, 没有用第三方的下拉刷新控件, 而是自己实现了一个简单的下拉刷新轮子, 然后赋值给UITableViewController
的public var refreshControl: UIRefreshControl?
属性. 主要原理就是判断下拉时的frame
变化:
// 监听frame的变化 addObserver(self, forKeyPath: "frame", options:.New, context: nil)
// 刷新的时候, 不再进行其他操作 private var isLoading = false override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) { let y = frame.origin.y // 1. 最开始一进来的时候, 刷新按钮是隐藏的, y就是-64, 需要先判断掉, y>=0 , 说明刷新控件已经完全缩回去了... if y >= 0 || y == -64 { return } // 2. 判断是否一进来就进行刷新 if beginAnimFlag && (y == -60.0 || y == -124.0){ if !isLoading { isLoading = true animtoringFlag = true tipView.beginLoadingAnimator() } return } // 3. 释放已经触发了刷新事件, 如果触发了, 需要进行旋转 if refreshing && !animtoringFlag { animtoringFlag = true tipView.beginLoadingAnimator() return } if y <= -50 && !rotationFlag { rotationFlag = true tipView.rotationRefresh(rotationFlag) }else if(y > -50 && rotationFlag){ rotationFlag = false tipView.rotationRefresh(rotationFlag) } }
③ 高斯模糊
: 使用的是系统自带的高斯模糊控件UIVisualEffectView
, 它是@available(iOS 8.0, *)
, 附一段简单的使用代码
private lazy var blurView : BlurView = { let blur = BlurView(effect: UIBlurEffect(style: .Light)) blur.categories = self.categories blur.delegate = self return blur }()
可以根据alpha = 0.5
, 调整alpha
来调整模糊效果, gif图中的高斯模糊效果不是很明显, 实际效果特别好.
④ 商城购物车动画
:这组动画还是比较简单的, 直接附代码, 如果有什么疑惑, 可以留言或者私信我
// MARK : - 动画相关懒加载 /// layer private lazy var animLayer : CALayer = { let layer = CALayer() layer.contentsGravity = kCAGravityResizeAspectFill; layer.bounds = CGRectMake(0, 0, 50, 50); layer.cornerRadius = CGRectGetHeight(layer.bounds) / 2 layer.masksToBounds = true; return layer }() /// 贝塞尔路径 private lazy var animPath = UIBezierPath() /// 动画组 private lazy var groupAnim : CAAnimationGroup = { let animation = CAKeyframeAnimation(keyPath: "position") animation.path = self.animPath.CGPath animation.rotationMode = kCAAnimationRotateAuto let expandAnimation = CABasicAnimation(keyPath: "transform.scale") expandAnimation.duration = 1 expandAnimation.fromValue = 0.5 expandAnimation.toValue = 2 expandAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn) let narrowAnimation = CABasicAnimation(keyPath: "transform.scale") // 先执行上面的, 然后再开始 narrowAnimation.beginTime = 1 narrowAnimation.duration = 0.5 narrowAnimation.fromValue = 2 narrowAnimation.toValue = 0.5 narrowAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut) let groups = CAAnimationGroup() groups.animations = [animation,expandAnimation,narrowAnimation] groups.duration = 1.5 groups.removedOnCompletion = false groups.fillMode = kCAFillModeForwards groups.delegate = self return groups }() // MARK: - 点击事件处理 private var num = 0 func gotoShopCar() { if num >= 99 { self.showErrorMessage("亲, 企业采购请联系我们客服") return } addtoCar.userInteractionEnabled = false // 设置layer // 贝塞尔弧线的起点 animLayer.position = addtoCar.center layer.addSublayer(animLayer) // 设置path animPath.moveToPoint(animLayer.position) let controlPointX = CGRectGetMaxX(addtoCar.frame) * 0.5 // 弧线, controlPoint基准点, endPoint结束点 animPath.addQuadCurveToPoint(shopCarBtn.center, controlPoint: CGPointMake(controlPointX, -frame.size.height * 5)) // 添加并开始动画 animLayer.addAnimation(groupAnim, forKey: "groups") } // MARK: - 动画的代理 // 动画停止的代理 override func animationDidStop(anim: CAAnimation, finished flag: Bool) { if anim == animLayer.animationForKey("groups")!{ animLayer.removeFromSuperlayer() animLayer.removeAllAnimations() num += 1 shopCarBtn.num = num let animation = CATransition() animation.duration = 0.25 shopCarBtn.layer.addAnimation(animation, forKey: nil) let shakeAnimation = CABasicAnimation(keyPath: "transform.translation.y") shakeAnimation.duration = 0.25 shakeAnimation.fromValue = -5 shakeAnimation.toValue = 5 shakeAnimation.autoreverses = true shopCarBtn.layer .addAnimation(shakeAnimation, forKey: nil) addtoCar.userInteractionEnabled = true } }
⑤ 主题详情页:商城详情页
的做法也是差不多的, 不过更简单一点.
关键一点在于, 详情页的展示主要依靠于H5
页面. 而我们需要根据webview
的高度来确定webviewCell
的高度.我的做法是监听UIWebView
的webViewDidFinishLoad
, 取出webView.scrollView.contentSize.height
然后给详情页发送一个通知, 让其刷新界面. 暂时没有想到更好的方法, 如果您有更好的做法, 请务必告诉我, 谢谢...
⑥ UIWebView
中图片的点击
第①步: 我们创建一个image.js
文件, 代码如下:
//setImage的作用是为页面的中img元素添加onClick事件,即设置点击时调用imageClick function setImageClick(){ var imgs = document.getElementsByTagName("img"); for (var i=0;i<imgs.length;i++){ var src = imgs[i].src; imgs[i].setAttribute("onClick","imageClick(src)"); } document.location = imageurls; } //imageClick即图片 onClick时触发的方法,document.location = url;的作用是使调用 //webView: shouldStartLoadWithRequest: navigationType:方法,在该方法中我们真正处理图片的点击 function imageClick(imagesrc){ var url="imageClick::"+imagesrc; document.location = url; }
第②步:在UIWebView
的代理方法webViewDidFinishLoad
中, 加载JS文件, 并给图片绑定绑定点击事件
// 加载js文件 webView.stringByEvaluatingJavaScriptFromString(try! String(contentsOfURL: NSBundle.mainBundle().URLForResource("image", withExtension: "js")!, encoding: NSUTF8StringEncoding)) // 给图片绑定点击事件 webView.stringByEvaluatingJavaScriptFromString("setImageClick()")
第③步:在UIWebView
的代理方法-webView:shouldStartLoadWithRequest:navigationType:
中判断图片的点击
func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool { let urlstr = request.URL?.absoluteString let components : [String] = urlstr!.componentsSeparatedByString("::") if (components.count >= 1) { //判断是不是图片点击 if (components[0] == "imageclick") { parentViewController?.presentViewController(ImageBrowserViewController(urls: [NSURL(string: components.last!)!], index: NSIndexPath(forItem: 0, inSection: 0)), animated: true, completion: nil) return false; } return true; } return true }
⑦ 登录/注册/忘记密码: 眼尖一点的朋友可能在上面的gif中已经发现, 花田小憩
中的登录/注册/忘记密码界面几乎是一样的, 我的做法是用一个控制器LoginViewController
来代表登录/注册/忘记密码
三个功能模块, 通过两个变量isRegister
和isRevPwd
来判断是哪个功能, 显示哪些界面, 我们点击注册
和忘记密码
的时候, 会执行代理方法:
// MARK: - LoginHeaderViewDelegate func loginHeaderView(loginHeaderView : LoginHeaderView, clickRevpwd pwdBtn: UIButton) { let login = LoginViewController() login.isRevPwd = true navigationController?.pushViewController(login, animated: true) } func loginHeaderView(loginHeaderView : LoginHeaderView, clickRegister registerbtn: UIButton) { let login = LoginViewController() login.isRegister = true navigationController?.pushViewController(login, animated: true) }
⑧ 验证码的倒计时
功能
/// 点击"发送验证码"按钮 func clickSafeNum(btn: UIButton) { var seconds = 10 //倒计时时间 let queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0) let timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0,queue); dispatch_source_set_timer(timer,dispatch_walltime(nil, 0),1 * NSEC_PER_SEC, 0); //每秒执行 dispatch_source_set_event_handler(timer) { if(seconds<=0){ //倒计时结束,关闭 dispatch_source_cancel(timer); dispatch_async(dispatch_get_main_queue(), { //设置界面的按钮显示 根据自己需求设置 btn.setTitleColor(UIColor.blackColor(), forState:.Normal) btn.setTitle("获取验证码", forState:.Normal) btn.titleLabel?.font = defaultFont14 btn.userInteractionEnabled = true }); }else{ dispatch_async(dispatch_get_main_queue(), { UIView.beginAnimations(nil, context: nil) UIView.setAnimationDuration(1) }) dispatch_async(dispatch_get_main_queue(), { //设置界面的按钮显示 根据自己需求设置 UIView.beginAnimations(nil, context: nil) UIView.setAnimationDuration(1) btn.setTitleColor(UIColor.orangeColor(), forState:.Normal) btn.setTitle("\(seconds)秒后重新发送", forState:.Normal) btn.titleLabel?.font = UIFont.systemFontOfSize(11) UIView.commitAnimations() btn.userInteractionEnabled = false }) seconds -= 1 } } dispatch_resume(timer) }
⑨ 设置
模块中给我们评分
这个功能在实际开发中特别常见:
代码如下, 很简单:
UIApplication.sharedApplication().openURL(NSURL(string: "itms-apps://itunes.apple.com/WebObjects/MZStore.woa/wa/viewSoftware?id=998252000")!)
其中最后的id需要填写你自己的APP在AppStore中的id, 打开iTunes
找到你自己的APP或者你想要的APP, 就能查看到id.
tip: 此功能测试的时候, 必须用真机!!!
⑩ 登录状态.
我们可以通过NSHTTPCookieStorage中的NSHTTPCookie来判断登录状态.也可以自定义一个字段来保存. 根据我抓包得知, 花田小憩
APP的做法是第一次登录后保存用户名和密码(MD5
加密的, 我测试过), 然后每次启动应用程序的时候, 会首先后台自动登录, 然后在进行评论/点赞等操作的时候呢, 参数中会带上用户的id.由于涉及到花田小憩
的账号密码的一些隐私, 所以登录/注册模块, 我就没有没有完整的写出来. 有兴趣的朋友可以私信我, 我可以把接口给你, 在此声明: 仅供学习, 毋做伤天害理之事
`tip: 我在AppDelegate.swift中给大家留了一个开关, 可以快速的进行登录状态的切换...
⑩+①: 个人/专栏
中心: 这两个功能是同一个控制器, 是UICollectionViewController
而不是UITableViewController
大家对UITableViewController
的header
应该很熟悉吧, 向上滑动的时候, 会停留在navigationBar的下面, 虽然UICollectionViewController
也可以设置header
, 但是在iOS9以前, 他是不能直接设置停留的.在iOS9之后, 可以一行代码设置header的停留
sectionHeadersPinToVisibleBounds = true
但是在iOS9之前, 我们需要自己实现这个功能:
// // LevitateHeaderFlowLayout.swift // Floral // // Created by ALin on 16/5/20. // Copyright © 2016年 ALin. All rights reserved. // 可以让header悬浮的流水布局 import UIKit class LevitateHeaderFlowLayout: UICollectionViewFlowLayout { override func prepareLayout() { super.prepareLayout() // 即使界面内容没有超过界面大小,也要竖直方向滑动 collectionView?.alwaysBounceVertical = true // sectionHeader停留 if #available(iOS 9.0, *) { sectionHeadersPinToVisibleBounds = true } } override func layoutAttributesForElementsInRect(rect: CGRect) -> [UICollectionViewLayoutAttributes]? { // 1. 获取父类返回的UICollectionViewLayoutAttributes数组 var answer = super.layoutAttributesForElementsInRect(rect)! // 2. 如果是iOS9.0以上, 直接返回父类的即可. 不用执行下面的操作了. 因为我们直接设置sectionHeadersPinToVisibleBounds = true即可 if #available(iOS 9.0, *) { return answer } // 3. 如果是iOS9.0以下的系统 // 以下代码来源:http://stackoverflow.com/questions/13511733/how-to-make-supplementary-view-float-in-uicollectionview-as-section-headers-do-i%3C/p%3E // 目的是让collectionview的header可以像tableview的header一样, 可以停留 // 创建一个索引集.(NSIndexSet:唯一的,有序的,无符号整数的集合) let missingSections = NSMutableIndexSet() // 遍历, 获取当前屏幕上的所有section for layoutAttributes in answer { // 如果是cell类型, 就加入索引集里面 if (layoutAttributes.representedElementCategory == UICollectionElementCategory.Cell) { missingSections.addIndex(layoutAttributes.indexPath.section) } } // 遍历, 将屏幕中拥有header的section从索引集中移除 for layoutAttributes in answer { // 如果是header, 移掉所在的数组 if (layoutAttributes.representedElementKind == UICollectionElementKindSectionHeader) { missingSections .removeIndex(layoutAttributes.indexPath.section) } } // 遍历当前屏幕没有header的索引集 missingSections.enumerateIndexesUsingBlock { (idx, _) in // 获取section中第一个indexpath let indexPath = NSIndexPath(forItem: 0, inSection: idx) // 获取其UICollectionViewLayoutAttributes let layoutAttributes = self.layoutAttributesForSupplementaryViewOfKind(UICollectionElementKindSectionHeader, atIndexPath: indexPath) // 如果有值, 就添加到UICollectionViewLayoutAttributes数组中去 if let _ = layoutAttributes{ answer.append(layoutAttributes!) } } // 遍历UICollectionViewLayoutAttributes数组, 更改header的值 for layoutAttributes in answer { // 如果是header, 改变其参数 if (layoutAttributes.representedElementKind==UICollectionElementKindSectionHeader) { // 获取header所在的section let section = layoutAttributes.indexPath.section // 获取section中cell总数 let numberOfItemsInSection = collectionView!.numberOfItemsInSection(section) // 获取第一个item的IndexPath let firstObjectIndexPath = NSIndexPath(forItem: 0, inSection: section) // 获取最后一个item的IndexPath let lastObjectIndexPath = NSIndexPath(forItem: max(0, (numberOfItemsInSection - 1)), inSection: section) // 定义两个变量来保存第一个和最后一个item的layoutAttributes属性 var firstObjectAttrs : UICollectionViewLayoutAttributes var lastObjectAttrs : UICollectionViewLayoutAttributes // 如果当前section中cell有值, 直接取出来即可 if (numberOfItemsInSection > 0) { firstObjectAttrs = self.layoutAttributesForItemAtIndexPath(firstObjectIndexPath)! lastObjectAttrs = self.layoutAttributesForItemAtIndexPath(lastObjectIndexPath)! } else { // 反之, 直接取header和footer的layoutAttributes属性 firstObjectAttrs = self.layoutAttributesForSupplementaryViewOfKind(UICollectionElementKindSectionHeader, atIndexPath: firstObjectIndexPath)! lastObjectAttrs = self.layoutAttributesForSupplementaryViewOfKind(UICollectionElementKindSectionFooter, atIndexPath: lastObjectIndexPath)! } // 获取当前header的高和origin let headerHeight = CGRectGetHeight(layoutAttributes.frame) var origin = layoutAttributes.frame.origin origin.y = min(// 2. 要保证在即将消失的临界点跟着消失 max( // 1. 需要保证header悬停, 所以取最大值 collectionView!.contentOffset.y + collectionView!.contentInset.top, (CGRectGetMinY(firstObjectAttrs.frame) - headerHeight) ), (CGRectGetMaxY(lastObjectAttrs.frame) - headerHeight) ) // 默认的层次关系是0. 这儿设置大于0即可.为什么设置成1024呢?因为我们是程序猿... layoutAttributes.zIndex = 1024 layoutAttributes.frame = CGRect(origin: origin, size: layoutAttributes.frame.size) } } return answer; } override func shouldInvalidateLayoutForBoundsChange(newBounds: CGRect) -> Bool { // 返回true, 表示一旦进行滑动, 就实时调用上面的-layoutAttributesForElementsInRect:方法 return true } }
⑩+@end: 整个项目, 东西还是蛮多的, 也不是仅仅几百上千字能说清楚的, 几乎每一个页面, 每一个文件, 我都有详细的中文注释. 希望大家一起进步. 这也是我的第一个开源的完整的Swift
项目, 有什么不足或者错误的地方, 希望大家指出来, 万分感激!!!
下载地址
如果对您有些许帮助, 请☆star
后续
可能有些功能模块存在bug, 后续我都会一一进行修复和完善的, 并更新在github
上.
如果您有任何疑问,或者发现bug以及不足的地方, 可以在下面给我留言, 或者关注我的新浪微博, 给我私信.
加载评论